From 27410856fedfdb593a8c968273411d12623e3cd3 Mon Sep 17 00:00:00 2001 From: Kyle Gordon Date: Sun, 16 Jun 2024 13:31:59 +0100 Subject: [PATCH 1/4] Octopus 11 attempt 2 --- custom_components/octopus_energy/__init__.py | 97 +- .../octopus_energy/api_client/__init__.py | 1098 +++++++++-------- .../octopus_energy/binary_sensor.py | 20 +- .../octopus_energy/config/__init__.py | 11 + .../octopus_energy/config/cost_tracker.py | 26 +- .../octopus_energy/config/main.py | 10 +- .../octopus_energy/config/target_rates.py | 68 +- .../octopus_energy/config_flow.py | 244 +++- custom_components/octopus_energy/const.py | 29 +- .../octopus_energy/coordinators/__init__.py | 2 +- .../octopus_energy/coordinators/account.py | 2 +- .../coordinators/current_consumption.py | 2 +- .../coordinators/electricity_rates.py | 23 +- .../octopus_energy/coordinators/gas_rates.py | 2 +- .../coordinators/intelligent_dispatches.py | 8 +- .../coordinators/intelligent_settings.py | 2 +- .../previous_consumption_and_rates.py | 74 +- .../coordinators/saving_sessions.py | 19 +- .../coordinators/wheel_of_fortune.py | 2 +- .../octopus_energy/cost_tracker/__init__.py | 66 +- .../cost_tracker/cost_tracker.py | 202 ++- .../octopus_energy/electricity/__init__.py | 65 +- .../octopus_energy/electricity/base.py | 2 +- .../current_accumulative_consumption.py | 44 +- .../electricity/current_accumulative_cost.py | 44 +- .../electricity/current_consumption.py | 3 +- .../electricity/current_demand.py | 3 +- .../electricity/current_rate.py | 18 +- .../octopus_energy/electricity/next_rate.py | 9 +- .../octopus_energy/electricity/off_peak.py | 35 +- .../previous_accumulative_consumption.py | 68 +- .../electricity/previous_accumulative_cost.py | 68 +- .../previous_accumulative_cost_override.py | 32 +- ...vious_accumulative_cost_override_tariff.py | 2 +- .../electricity/previous_rate.py | 9 +- .../electricity/rates_current_day.py | 2 +- .../electricity/rates_next_day.py | 2 +- .../electricity/rates_previous_consumption.py | 2 +- .../rates_previous_consumption_override.py | 2 +- .../electricity/rates_previous_day.py | 2 +- .../electricity/standing_charge.py | 9 +- .../octopus_energy/gas/__init__.py | 3 +- ...t_accumulative_consumption_cubic_meters.py | 7 +- .../current_accumulative_consumption_kwh.py | 7 +- .../gas/current_accumulative_cost.py | 9 +- .../octopus_energy/gas/current_consumption.py | 4 +- .../octopus_energy/gas/current_rate.py | 18 +- .../octopus_energy/gas/next_rate.py | 9 +- ...s_accumulative_consumption_cubic_meters.py | 16 +- .../previous_accumulative_consumption_kwh.py | 12 +- .../gas/previous_accumulative_cost.py | 13 +- .../previous_accumulative_cost_override.py | 25 +- ...vious_accumulative_cost_override_tariff.py | 2 +- .../octopus_energy/gas/previous_rate.py | 9 +- .../octopus_energy/gas/rates_current_day.py | 2 +- .../octopus_energy/gas/rates_next_day.py | 2 +- .../gas/rates_previous_consumption.py | 2 +- .../rates_previous_consumption_override.py | 2 +- .../octopus_energy/gas/rates_previous_day.py | 2 +- .../octopus_energy/gas/standing_charge.py | 7 +- .../octopus_energy/intelligent/__init__.py | 87 +- .../octopus_energy/intelligent/base.py | 9 +- .../octopus_energy/intelligent/bump_charge.py | 3 +- .../intelligent/charge_limit.py | 9 +- .../octopus_energy/intelligent/dispatching.py | 77 +- .../octopus_energy/intelligent/ready_time.py | 3 +- .../intelligent/smart_charge.py | 3 +- .../octopus_energy/manifest.json | 2 +- custom_components/octopus_energy/number.py | 6 +- .../octopus_energy/octoplus/points.py | 64 +- .../octoplus/saving_sessions.py | 3 +- .../octoplus/saving_sessions_events.py | 2 +- custom_components/octopus_energy/sensor.py | 205 ++- .../octopus_energy/services.yaml | 102 ++ .../octopus_energy/statistics/__init__.py | 107 +- .../octopus_energy/statistics/consumption.py | 81 +- .../octopus_energy/statistics/cost.py | 84 +- .../octopus_energy/statistics/refresh.py | 71 +- custom_components/octopus_energy/switch.py | 5 +- .../octopus_energy/target_rates/__init__.py | 62 +- .../target_rates/target_rate.py | 60 +- custom_components/octopus_energy/time.py | 5 +- .../octopus_energy/translations/en.json | 76 +- .../octopus_energy/utils/__init__.py | 43 +- .../octopus_energy/utils/attributes.py | 7 +- .../octopus_energy/utils/rate_information.py | 73 +- .../octopus_energy/utils/requests.py | 11 + .../wheel_of_fortune/electricity_spins.py | 3 +- .../wheel_of_fortune/gas_spins.py | 3 +- 89 files changed, 2581 insertions(+), 1264 deletions(-) diff --git a/custom_components/octopus_energy/__init__.py b/custom_components/octopus_energy/__init__.py index 547218edd..3fea27ebf 100644 --- a/custom_components/octopus_energy/__init__.py +++ b/custom_components/octopus_energy/__init__.py @@ -5,18 +5,23 @@ from homeassistant.helpers import device_registry as dr from homeassistant.components.recorder import get_instance from homeassistant.util.dt import (utcnow) +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STOP +) from .coordinators.account import AccountCoordinatorResult, async_setup_account_info_coordinator from .coordinators.intelligent_dispatches import async_setup_intelligent_dispatches_coordinator from .coordinators.intelligent_settings import async_setup_intelligent_settings_coordinator from .coordinators.electricity_rates import async_setup_electricity_rates_coordinator from .coordinators.saving_sessions import async_setup_saving_sessions_coordinators +from .coordinators.greenness_forecast import async_setup_greenness_forecast_coordinator from .statistics import get_statistic_ids_to_remove from .intelligent import async_mock_intelligent_data, get_intelligent_features, is_intelligent_tariff, mock_intelligent_device from .config.main import async_migrate_main_config from .config.target_rates import async_migrate_target_config from .utils import get_active_tariff_code +from .utils.tariff_overrides import async_get_tariff_override from .const import ( CONFIG_KIND, @@ -34,8 +39,6 @@ CONFIG_ACCOUNT_ID, CONFIG_MAIN_ELECTRICITY_PRICE_CAP, CONFIG_MAIN_GAS_PRICE_CAP, - - CONFIG_TARGET_NAME, DATA_CLIENT, DATA_ELECTRICITY_RATES_COORDINATOR_KEY, @@ -52,6 +55,12 @@ SCAN_INTERVAL = timedelta(minutes=1) +async def async_remove_config_entry_device( + hass, config_entry, device_entry +) -> bool: + """Remove a config entry from a device.""" + return True + async def async_migrate_entry(hass, config_entry): """Migrate old entry.""" if (config_entry.version < CONFIG_VERSION): @@ -75,6 +84,13 @@ async def async_migrate_entry(hass, config_entry): return True +async def _async_close_client(hass, account_id: str): + if account_id in hass.data[DOMAIN] and DATA_CLIENT in hass.data[DOMAIN][account_id]: + _LOGGER.debug('Closing client...') + client: OctopusEnergyApiClient = hass.data[DOMAIN][account_id][DATA_CLIENT] + await client.async_close() + _LOGGER.debug('Client closed.') + async def async_setup_entry(hass, entry): """This is called from the config flow.""" hass.data.setdefault(DOMAIN, {}) @@ -90,6 +106,22 @@ async def async_setup_entry(hass, entry): if config[CONFIG_KIND] == CONFIG_KIND_ACCOUNT: await async_setup_dependencies(hass, config) await hass.config_entries.async_forward_entry_setups(entry, ACCOUNT_PLATFORMS) + + async def async_close_connection(_) -> None: + """Close client.""" + await _async_close_client(hass, account_id) + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_close_connection) + ) + + # If the main account has been reloaded, then reload all other entries to make sure they're referencing + # the correct references (e.g. rate coordinators) + child_entries = hass.config_entries.async_entries(DOMAIN) + for child_entry in child_entries: + if child_entry.data[CONFIG_KIND] != CONFIG_KIND_ACCOUNT and child_entry.data[CONFIG_ACCOUNT_ID] == account_id: + await hass.config_entries.async_reload(child_entry.entry_id) + elif config[CONFIG_KIND] == CONFIG_KIND_TARGET_RATE: if DOMAIN not in hass.data or account_id not in hass.data[DOMAIN] or DATA_ACCOUNT not in hass.data[DOMAIN][account_id]: raise ConfigEntryNotReady("Account has not been setup") @@ -148,6 +180,8 @@ async def async_setup_dependencies(hass, config): _LOGGER.info(f'electricity_price_cap: {electricity_price_cap}') _LOGGER.info(f'gas_price_cap: {gas_price_cap}') + # Close any existing clients, as our new client may have changed + await _async_close_client(hass, account_id) client = OctopusEnergyApiClient(config[CONFIG_MAIN_API_KEY], electricity_price_cap, gas_price_cap) hass.data[DOMAIN][account_id][DATA_CLIENT] = client @@ -163,31 +197,48 @@ async def async_setup_dependencies(hass, config): hass.data[DOMAIN][account_id][DATA_ACCOUNT] = AccountCoordinatorResult(utcnow(), 1, account_info) - # Remove gas meter devices which had incorrect identifier + device_registry = dr.async_get(hass) + now = utcnow() + if account_info is not None and len(account_info["gas_meter_points"]) > 0: - device_registry = dr.async_get(hass) for point in account_info["gas_meter_points"]: mprn = point["mprn"] for meter in point["meters"]: serial_number = meter["serial_number"] - intelligent_device = device_registry.async_get_device(identifiers={(DOMAIN, f"electricity_{serial_number}_{mprn}")}) - if intelligent_device is not None: - device_registry.async_remove_device(intelligent_device.id) + + tariff_code = get_active_tariff_code(now, point["agreements"]) + if tariff_code is None: + gas_device = device_registry.async_get_device(identifiers={(DOMAIN, f"gas_{serial_number}_{mprn}")}) + if gas_device is not None: + _LOGGER.debug(f'Removed gas device {serial_number}/{mprn} due to no active tariff') + device_registry.async_remove_device(gas_device.id) + + # Remove gas meter devices which had incorrect identifier + gas_device = device_registry.async_get_device(identifiers={(DOMAIN, f"electricity_{serial_number}_{mprn}")}) + if gas_device is not None: + device_registry.async_remove_device(gas_device.id) has_intelligent_tariff = False intelligent_mpan = None intelligent_serial_number = None - now = utcnow() for point in account_info["electricity_meter_points"]: - # We only care about points that have active agreements + mpan = point["mpan"] electricity_tariff_code = get_active_tariff_code(now, point["agreements"]) - if electricity_tariff_code is not None: - for meter in point["meters"]: + + for meter in point["meters"]: + serial_number = meter["serial_number"] + + if electricity_tariff_code is not None: if meter["is_export"] == False: if is_intelligent_tariff(electricity_tariff_code): - intelligent_mpan = point["mpan"] - intelligent_serial_number = meter["serial_number"] + intelligent_mpan = mpan + intelligent_serial_number = serial_number has_intelligent_tariff = True + else: + _LOGGER.debug(f'Removed electricity device {serial_number}/{mpan} due to no active tariff') + electricity_device = device_registry.async_get_device(identifiers={(DOMAIN, f"electricity_{serial_number}_{mpan}")}) + if electricity_device is not None: + device_registry.async_remove_device(electricity_device.id) should_mock_intelligent_data = await async_mock_intelligent_data(hass, account_id) if should_mock_intelligent_data: @@ -209,9 +260,10 @@ async def async_setup_dependencies(hass, config): else: intelligent_device = await client.async_get_intelligent_device(account_id) - hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DEVICE] = intelligent_device - hass.data[DOMAIN][account_id][DATA_INTELLIGENT_MPAN] = intelligent_mpan - hass.data[DOMAIN][account_id][DATA_INTELLIGENT_SERIAL_NUMBER] = intelligent_serial_number + if intelligent_device is not None: + hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DEVICE] = intelligent_device + hass.data[DOMAIN][account_id][DATA_INTELLIGENT_MPAN] = intelligent_mpan + hass.data[DOMAIN][account_id][DATA_INTELLIGENT_SERIAL_NUMBER] = intelligent_serial_number for point in account_info["electricity_meter_points"]: # We only care about points that have active agreements @@ -222,8 +274,9 @@ async def async_setup_dependencies(hass, config): serial_number = meter["serial_number"] is_export_meter = meter["is_export"] is_smart_meter = meter["is_smart_meter"] - planned_dispatches_supported = get_intelligent_features(intelligent_device["provider"]).planned_dispatches_supported if intelligent_device is not None else True - await async_setup_electricity_rates_coordinator(hass, account_id, mpan, serial_number, is_smart_meter, is_export_meter, planned_dispatches_supported) + tariff_override = await async_get_tariff_override(hass, mpan, serial_number) + planned_dispatches_supported = get_intelligent_features(intelligent_device.provider).planned_dispatches_supported if intelligent_device is not None else True + await async_setup_electricity_rates_coordinator(hass, account_id, mpan, serial_number, is_smart_meter, is_export_meter, planned_dispatches_supported, tariff_override) await async_setup_account_info_coordinator(hass, account_id) @@ -233,6 +286,8 @@ async def async_setup_dependencies(hass, config): await async_setup_saving_sessions_coordinators(hass, account_id) + await async_setup_greenness_forecast_coordinator(hass, account_id) + async def options_update_listener(hass, entry): """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) @@ -241,10 +296,12 @@ async def async_unload_entry(hass, entry): """Unload a config entry.""" unload_ok = False - if CONFIG_MAIN_API_KEY in entry.data: + if entry.data[CONFIG_KIND] == CONFIG_KIND_ACCOUNT: unload_ok = await hass.config_entries.async_unload_platforms(entry, ACCOUNT_PLATFORMS) - elif CONFIG_TARGET_NAME in entry.data: + elif entry.data[CONFIG_KIND] == CONFIG_KIND_TARGET_RATE: unload_ok = await hass.config_entries.async_unload_platforms(entry, TARGET_RATE_PLATFORMS) + elif entry.data[CONFIG_KIND] == CONFIG_KIND_COST_TRACKER: + unload_ok = await hass.config_entries.async_unload_platforms(entry, COST_TRACKER_PLATFORMS) return unload_ok diff --git a/custom_components/octopus_energy/api_client/__init__.py b/custom_components/octopus_energy/api_client/__init__.py index fd5e757ed..d24d8439e 100644 --- a/custom_components/octopus_energy/api_client/__init__.py +++ b/custom_components/octopus_energy/api_client/__init__.py @@ -13,10 +13,14 @@ get_tariff_parts, ) + +from .intelligent_device import IntelligentDevice +from .octoplus import RedeemOctoplusPointsResponse from .intelligent_settings import IntelligentSettings from .intelligent_dispatches import IntelligentDispatchItem, IntelligentDispatches from .saving_sessions import JoinSavingSessionResponse, SavingSession, SavingSessionsResponse from .wheel_of_fortune import WheelOfFortuneSpinsResponse +from .greenness_forecast import GreennessForecast _LOGGER = logging.getLogger(__name__) @@ -52,31 +56,15 @@ firmwareVersion }} }} - agreements {{ + agreements(includeInactive: true) {{ validFrom validTo - tariff {{ - ...on StandardTariff {{ - tariffCode - productCode - }} - ...on DayNightTariff {{ - tariffCode - productCode - }} - ...on ThreeRateTariff {{ - tariffCode + tariff {{ + ... on TariffType {{ productCode - }} - ...on HalfHourlyTariff {{ - tariffCode - productCode - }} - ...on PrepayTariff {{ - tariffCode - productCode - }} - }} + tariffCode + }} + }} }} }} }} @@ -95,7 +83,7 @@ firmwareVersion }} }} - agreements {{ + agreements(includeInactive: true) {{ validFrom validTo tariff {{ @@ -251,12 +239,13 @@ octoplus_saving_session_query = '''query {{ savingSessions {{ - events {{ + events(getDevEvents: false) {{ id code rewardPerKwhInOctoPoints startAt endAt + devEvent }} account(accountNumber: "{account_id}") {{ hasJoinedCampaign @@ -289,6 +278,26 @@ }} }}''' +greenness_forecast_query = '''query { + greennessForecast { + validFrom + validTo + greennessScore + greennessIndex + highlightFlag + } +}''' + +redeem_octoplus_points_account_credit_mutation = '''mutation {{ + redeemLoyaltyPointsForAccountCredit(input: {{ + accountNumber: "{account_id}", + points: {points} + }}) {{ + pointsRedeemed + }} +}} +''' + user_agent_value = "bottlecapdave-home-assistant-octopus-energy" def get_valid_from(rate): @@ -365,6 +374,7 @@ def __init__(self, message: str, errors: list[str]): class OctopusEnergyApiClient: _refresh_token_lock = RLock() + _session_lock = RLock() def __init__(self, api_key, electricity_price_cap = None, gas_price_cap = None, timeout_in_seconds = 20): if (api_key is None): @@ -384,6 +394,20 @@ def __init__(self, api_key, electricity_price_cap = None, gas_price_cap = None, self._timeout = aiohttp.ClientTimeout(total=None, sock_connect=timeout_in_seconds, sock_read=timeout_in_seconds) self._default_headers = { "user-agent": f'{user_agent_value}/{INTEGRATION_VERSION}' } + self._session = None + + async def async_close(self): + with self._session_lock: + await self._session.close() + + def _create_client_session(self): + if self._session is not None: + return self._session + + with self._session_lock: + self._session = aiohttp.ClientSession(timeout=self._timeout, headers=self._default_headers) + return self._session + async def async_refresh_token(self): """Get the user's refresh token""" if (self._graphql_expiration is not None and (self._graphql_expiration - timedelta(minutes=5)) > now()): @@ -395,21 +419,21 @@ async def async_refresh_token(self): return try: - async with aiohttp.ClientSession(timeout=self._timeout, headers=self._default_headers) as client: - url = f'{self._base_url}/v1/graphql/' - payload = { "query": api_token_query.format(api_key=self._api_key) } - async with client.post(url, json=payload) as token_response: - token_response_body = await self.__async_read_response__(token_response, url) - if (token_response_body is not None and - "data" in token_response_body and - "obtainKrakenToken" in token_response_body["data"] and - token_response_body["data"]["obtainKrakenToken"] is not None and - "token" in token_response_body["data"]["obtainKrakenToken"]): - - self._graphql_token = token_response_body["data"]["obtainKrakenToken"]["token"] - self._graphql_expiration = now() + timedelta(hours=1) - else: - _LOGGER.error("Failed to retrieve auth token") + client = self._create_client_session() + url = f'{self._base_url}/v1/graphql/' + payload = { "query": api_token_query.format(api_key=self._api_key) } + async with client.post(url, json=payload) as token_response: + token_response_body = await self.__async_read_response__(token_response, url) + if (token_response_body is not None and + "data" in token_response_body and + "obtainKrakenToken" in token_response_body["data"] and + token_response_body["data"]["obtainKrakenToken"] is not None and + "token" in token_response_body["data"]["obtainKrakenToken"]): + + self._graphql_token = token_response_body["data"]["obtainKrakenToken"]["token"] + self._graphql_expiration = now() + timedelta(hours=1) + else: + _LOGGER.error("Failed to retrieve auth token") except TimeoutError: _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') raise TimeoutException() @@ -419,82 +443,46 @@ async def async_get_account(self, account_id): await self.async_refresh_token() try: - async with aiohttp.ClientSession(timeout=self._timeout, headers=self._default_headers) as client: - url = f'{self._base_url}/v1/graphql/' - # Get account response - payload = { "query": account_query.format(account_id=account_id) } - headers = { "Authorization": f"JWT {self._graphql_token}" } - async with client.post(url, json=payload, headers=headers) as account_response: - account_response_body = await self.__async_read_response__(account_response, url) - - _LOGGER.debug(f'account: {account_response_body}') - - if (account_response_body is not None and - "data" in account_response_body and - "account" in account_response_body["data"] and - account_response_body["data"]["account"] is not None): - return { - "id": account_id, - "octoplus_enrolled": account_response_body["data"]["octoplusAccountInfo"]["isOctoplusEnrolled"] == True - if "octoplusAccountInfo" in account_response_body["data"] and "isOctoplusEnrolled" in account_response_body["data"]["octoplusAccountInfo"] - else False, - "electricity_meter_points": list(map(lambda mp: { - "mpan": mp["meterPoint"]["mpan"], - "meters": list(map(lambda m: { - "serial_number": m["serialNumber"], - "is_export": m["smartExportElectricityMeter"] is not None, - "is_smart_meter": f'{m["meterType"]}'.startswith("S1") or f'{m["meterType"]}'.startswith("S2"), - "device_id": m["smartImportElectricityMeter"]["deviceId"] if m["smartImportElectricityMeter"] is not None else None, - "manufacturer": m["smartImportElectricityMeter"]["manufacturer"] - if m["smartImportElectricityMeter"] is not None - else m["smartExportElectricityMeter"]["manufacturer"] - if m["smartExportElectricityMeter"] is not None - else m["makeAndType"], - "model": m["smartImportElectricityMeter"]["model"] - if m["smartImportElectricityMeter"] is not None - else m["smartExportElectricityMeter"]["model"] - if m["smartExportElectricityMeter"] is not None - else None, - "firmware": m["smartImportElectricityMeter"]["firmwareVersion"] - if m["smartImportElectricityMeter"] is not None - else m["smartExportElectricityMeter"]["firmwareVersion"] - if m["smartExportElectricityMeter"] is not None - else None - }, - mp["meterPoint"]["meters"] - if "meterPoint" in mp and "meters" in mp["meterPoint"] and mp["meterPoint"]["meters"] is not None - else [] - )), - "agreements": list(map(lambda a: { - "start": a["validFrom"], - "end": a["validTo"], - "tariff_code": a["tariff"]["tariffCode"] if "tariff" in a and "tariffCode" in a["tariff"] else None, - "product_code": a["tariff"]["productCode"] if "tariff" in a and "productCode" in a["tariff"] else None, - }, - mp["meterPoint"]["agreements"] - if "meterPoint" in mp and "agreements" in mp["meterPoint"] and mp["meterPoint"]["agreements"] is not None - else [] - )) - }, - account_response_body["data"]["account"]["electricityAgreements"] - if "electricityAgreements" in account_response_body["data"]["account"] and account_response_body["data"]["account"]["electricityAgreements"] is not None - else [] - )), - "gas_meter_points": list(map(lambda mp: { - "mprn": mp["meterPoint"]["mprn"], + client = self._create_client_session() + url = f'{self._base_url}/v1/graphql/' + # Get account response + payload = { "query": account_query.format(account_id=account_id) } + headers = { "Authorization": f"JWT {self._graphql_token}" } + async with client.post(url, json=payload, headers=headers) as account_response: + account_response_body = await self.__async_read_response__(account_response, url) + + _LOGGER.debug(f'account: {account_response_body}') + + if (account_response_body is not None and + "data" in account_response_body and + "account" in account_response_body["data"] and + account_response_body["data"]["account"] is not None): + return { + "id": account_id, + "octoplus_enrolled": account_response_body["data"]["octoplusAccountInfo"]["isOctoplusEnrolled"] == True + if "octoplusAccountInfo" in account_response_body["data"] and "isOctoplusEnrolled" in account_response_body["data"]["octoplusAccountInfo"] + else False, + "electricity_meter_points": list(map(lambda mp: { + "mpan": mp["meterPoint"]["mpan"], "meters": list(map(lambda m: { "serial_number": m["serialNumber"], - "consumption_units": m["consumptionUnits"], - "is_smart_meter": m["mechanism"] == "S1" or m["mechanism"] == "S2", - "device_id": m["smartGasMeter"]["deviceId"] if m["smartGasMeter"] is not None else None, - "manufacturer": m["smartGasMeter"]["manufacturer"] - if m["smartGasMeter"] is not None - else m["modelName"], - "model": m["smartGasMeter"]["model"] - if m["smartGasMeter"] is not None + "is_export": m["smartExportElectricityMeter"] is not None, + "is_smart_meter": f'{m["meterType"]}'.startswith("S1") or f'{m["meterType"]}'.startswith("S2"), + "device_id": m["smartImportElectricityMeter"]["deviceId"] if m["smartImportElectricityMeter"] is not None else None, + "manufacturer": m["smartImportElectricityMeter"]["manufacturer"] + if m["smartImportElectricityMeter"] is not None + else m["smartExportElectricityMeter"]["manufacturer"] + if m["smartExportElectricityMeter"] is not None + else m["makeAndType"], + "model": m["smartImportElectricityMeter"]["model"] + if m["smartImportElectricityMeter"] is not None + else m["smartExportElectricityMeter"]["model"] + if m["smartExportElectricityMeter"] is not None else None, - "firmware": m["smartGasMeter"]["firmwareVersion"] - if m["smartGasMeter"] is not None + "firmware": m["smartImportElectricityMeter"]["firmwareVersion"] + if m["smartImportElectricityMeter"] is not None + else m["smartExportElectricityMeter"]["firmwareVersion"] + if m["smartExportElectricityMeter"] is not None else None }, mp["meterPoint"]["meters"] @@ -502,59 +490,120 @@ async def async_get_account(self, account_id): else [] )), "agreements": list(map(lambda a: { - "start": a["validFrom"], - "end": a["validTo"], - "tariff_code": a["tariff"]["tariffCode"] if "tariff" in a and "tariffCode" in a["tariff"] else None, - "product_code": a["tariff"]["productCode"] if "tariff" in a and "productCode" in a["tariff"] else None, - }, - mp["meterPoint"]["agreements"] - if "meterPoint" in mp and "agreements" in mp["meterPoint"] and mp["meterPoint"]["agreements"] is not None - else [] - )) - }, - account_response_body["data"]["account"]["gasAgreements"] - if "gasAgreements" in account_response_body["data"]["account"] and account_response_body["data"]["account"]["gasAgreements"] is not None - else [] - )), - } - else: - _LOGGER.error("Failed to retrieve account") + "start": a["validFrom"], + "end": a["validTo"], + "tariff_code": a["tariff"]["tariffCode"] if "tariff" in a and "tariffCode" in a["tariff"] else None, + "product_code": a["tariff"]["productCode"] if "tariff" in a and "productCode" in a["tariff"] else None, + }, + mp["meterPoint"]["agreements"] + if "meterPoint" in mp and "agreements" in mp["meterPoint"] and mp["meterPoint"]["agreements"] is not None + else [] + )) + }, + account_response_body["data"]["account"]["electricityAgreements"] + if "electricityAgreements" in account_response_body["data"]["account"] and account_response_body["data"]["account"]["electricityAgreements"] is not None + else [] + )), + "gas_meter_points": list(map(lambda mp: { + "mprn": mp["meterPoint"]["mprn"], + "meters": list(map(lambda m: { + "serial_number": m["serialNumber"], + "consumption_units": m["consumptionUnits"], + "is_smart_meter": m["mechanism"] == "S1" or m["mechanism"] == "S2", + "device_id": m["smartGasMeter"]["deviceId"] if m["smartGasMeter"] is not None else None, + "manufacturer": m["smartGasMeter"]["manufacturer"] + if m["smartGasMeter"] is not None + else m["modelName"], + "model": m["smartGasMeter"]["model"] + if m["smartGasMeter"] is not None + else None, + "firmware": m["smartGasMeter"]["firmwareVersion"] + if m["smartGasMeter"] is not None + else None + }, + mp["meterPoint"]["meters"] + if "meterPoint" in mp and "meters" in mp["meterPoint"] and mp["meterPoint"]["meters"] is not None + else [] + )), + "agreements": list(map(lambda a: { + "start": a["validFrom"], + "end": a["validTo"], + "tariff_code": a["tariff"]["tariffCode"] if "tariff" in a and "tariffCode" in a["tariff"] else None, + "product_code": a["tariff"]["productCode"] if "tariff" in a and "productCode" in a["tariff"] else None, + }, + mp["meterPoint"]["agreements"] + if "meterPoint" in mp and "agreements" in mp["meterPoint"] and mp["meterPoint"]["agreements"] is not None + else [] + )) + }, + account_response_body["data"]["account"]["gasAgreements"] + if "gasAgreements" in account_response_body["data"]["account"] and account_response_body["data"]["account"]["gasAgreements"] is not None + else [] + )), + } + else: + _LOGGER.error("Failed to retrieve account") except TimeoutError: _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') raise TimeoutException() return None + + async def async_get_greenness_forecast(self) -> list[GreennessForecast]: + """Get the latest greenness forecast""" + await self.async_refresh_token() + + try: + client = self._create_client_session() + url = f'{self._base_url}/v1/graphql/' + payload = { "query": greenness_forecast_query } + headers = { "Authorization": f"JWT {self._graphql_token}" } + async with client.post(url, json=payload, headers=headers) as greenness_forecast_response: + + response_body = await self.__async_read_response__(greenness_forecast_response, url) + if (response_body is not None and "data" in response_body and "greennessForecast" in response_body["data"]): + forecast = list(map(lambda item: GreennessForecast(as_utc(parse_datetime(item["validFrom"])), + as_utc(parse_datetime(item["validTo"])), + int(item["greennessScore"]), + item["greennessIndex"], + item["highlightFlag"]), + response_body["data"]["greennessForecast"])) + forecast.sort(key=lambda item: item.start) + return forecast + + except TimeoutError: + _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') + raise TimeoutException() async def async_get_saving_sessions(self, account_id: str) -> SavingSessionsResponse: """Get the user's seasons savings""" await self.async_refresh_token() try: - async with aiohttp.ClientSession(timeout=self._timeout, headers=self._default_headers) as client: - url = f'{self._base_url}/v1/graphql/' - # Get account response - payload = { "query": octoplus_saving_session_query.format(account_id=account_id) } - headers = { "Authorization": f"JWT {self._graphql_token}" } - async with client.post(url, json=payload, headers=headers) as account_response: - response_body = await self.__async_read_response__(account_response, url) - - if (response_body is not None and "data" in response_body): - return SavingSessionsResponse(list(map(lambda ev: SavingSession(ev["id"], - ev["code"], - as_utc(parse_datetime(ev["startAt"])), - as_utc(parse_datetime(ev["endAt"])), - ev["rewardPerKwhInOctoPoints"]), - response_body["data"]["savingSessions"]["events"])), - list(map(lambda ev: SavingSession(ev["eventId"], - None, - as_utc(parse_datetime(ev["startAt"])), - as_utc(parse_datetime(ev["endAt"])), - ev["rewardGivenInOctoPoints"]), - response_body["data"]["savingSessions"]["account"]["joinedEvents"]))) - else: - _LOGGER.error("Failed to retrieve saving sessions") - + client = self._create_client_session() + url = f'{self._base_url}/v1/graphql/' + # Get account response + payload = { "query": octoplus_saving_session_query.format(account_id=account_id) } + headers = { "Authorization": f"JWT {self._graphql_token}" } + async with client.post(url, json=payload, headers=headers) as account_response: + response_body = await self.__async_read_response__(account_response, url) + + if (response_body is not None and "data" in response_body): + return SavingSessionsResponse(list(map(lambda ev: SavingSession(ev["id"], + ev["code"], + as_utc(parse_datetime(ev["startAt"])), + as_utc(parse_datetime(ev["endAt"])), + ev["rewardPerKwhInOctoPoints"]), + response_body["data"]["savingSessions"]["events"])), + list(map(lambda ev: SavingSession(ev["eventId"], + None, + as_utc(parse_datetime(ev["startAt"])), + as_utc(parse_datetime(ev["endAt"])), + ev["rewardGivenInOctoPoints"]), + response_body["data"]["savingSessions"]["account"]["joinedEvents"]))) + else: + _LOGGER.error("Failed to retrieve saving sessions") except TimeoutError: _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') raise TimeoutException() @@ -566,18 +615,18 @@ async def async_get_octoplus_points(self): await self.async_refresh_token() try: - async with aiohttp.ClientSession(timeout=self._timeout, headers=self._default_headers) as client: - url = f'{self._base_url}/v1/graphql/' - # Get account response - payload = { "query": octoplus_points_query } - headers = { "Authorization": f"JWT {self._graphql_token}" } - async with client.post(url, json=payload, headers=headers) as account_response: - response_body = await self.__async_read_response__(account_response, url) - - if (response_body is not None and "data" in response_body and "loyaltyPointLedgers" in response_body["data"] and len(response_body["data"]["loyaltyPointLedgers"]) > 0): - return int(response_body["data"]["loyaltyPointLedgers"][0]["balanceCarriedForward"]) - else: - _LOGGER.error("Failed to retrieve octopoints") + client = self._create_client_session() + url = f'{self._base_url}/v1/graphql/' + # Get account response + payload = { "query": octoplus_points_query } + headers = { "Authorization": f"JWT {self._graphql_token}" } + async with client.post(url, json=payload, headers=headers) as account_response: + response_body = await self.__async_read_response__(account_response, url) + + if (response_body is not None and "data" in response_body and "loyaltyPointLedgers" in response_body["data"] and len(response_body["data"]["loyaltyPointLedgers"]) > 0): + return int(response_body["data"]["loyaltyPointLedgers"][0]["balanceCarriedForward"]) + else: + _LOGGER.error("Failed to retrieve octopoints") except TimeoutError: _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') @@ -586,22 +635,42 @@ async def async_get_octoplus_points(self): return None async def async_join_octoplus_saving_session(self, account_id: str, event_code: str) -> JoinSavingSessionResponse: - """Get the user's octoplus points""" + """Join a saving session""" await self.async_refresh_token() try: - async with aiohttp.ClientSession(timeout=self._timeout, headers=self._default_headers) as client: - url = f'{self._base_url}/v1/graphql/' - # Get account response - payload = { "query": octoplus_saving_session_join_mutation.format(account_id=account_id, event_code=event_code) } - headers = { "Authorization": f"JWT {self._graphql_token}" } - async with client.post(url, json=payload, headers=headers) as account_response: - - try: - await self.__async_read_response__(account_response, url) - return JoinSavingSessionResponse(True, []) - except RequestException as e: - return JoinSavingSessionResponse(False, e.errors) + client = self._create_client_session() + url = f'{self._base_url}/v1/graphql/' + # Get account response + payload = { "query": octoplus_saving_session_join_mutation.format(account_id=account_id, event_code=event_code) } + headers = { "Authorization": f"JWT {self._graphql_token}" } + async with client.post(url, json=payload, headers=headers) as join_response: + + try: + await self.__async_read_response__(join_response, url) + return JoinSavingSessionResponse(True, []) + except RequestException as e: + return JoinSavingSessionResponse(False, e.errors) + + except TimeoutError: + _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') + raise TimeoutException() + + async def async_redeem_octoplus_points_into_account_credit(self, account_id: str, points_to_redeem: int) -> RedeemOctoplusPointsResponse: + """Redeem octoplus points""" + await self.async_refresh_token() + + try: + client = self._create_client_session() + url = f'{self._base_url}/v1/graphql/' + payload = { "query": redeem_octoplus_points_account_credit_mutation.format(account_id=account_id, points=points_to_redeem) } + headers = { "Authorization": f"JWT {self._graphql_token}" } + async with client.post(url, json=payload, headers=headers) as redemption_response: + try: + await self.__async_read_response__(redemption_response, url) + return RedeemOctoplusPointsResponse(True, []) + except RequestException as e: + return RedeemOctoplusPointsResponse(False, e.errors) except TimeoutError: _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') @@ -612,23 +681,23 @@ async def async_get_smart_meter_consumption(self, device_id: str, period_from: d await self.async_refresh_token() try: - async with aiohttp.ClientSession(timeout=self._timeout, headers=self._default_headers) as client: - url = f'{self._base_url}/v1/graphql/' - - payload = { "query": live_consumption_query.format(device_id=device_id, period_from=period_from.strftime("%Y-%m-%dT%H:%M:%S%z"), period_to=period_to.strftime("%Y-%m-%dT%H:%M:%S%z")) } - headers = { "Authorization": f"JWT {self._graphql_token}" } - async with client.post(url, json=payload, headers=headers) as live_consumption_response: - response_body = await self.__async_read_response__(live_consumption_response, url) - - if (response_body is not None and "data" in response_body and "smartMeterTelemetry" in response_body["data"] and response_body["data"]["smartMeterTelemetry"] is not None and len(response_body["data"]["smartMeterTelemetry"]) > 0): - return list(map(lambda mp: { - "consumption": float(mp["consumptionDelta"]) / 1000, - "demand": float(mp["demand"]) if "demand" in mp and mp["demand"] is not None else None, - "start": parse_datetime(mp["readAt"]), - "end": parse_datetime(mp["readAt"]) + timedelta(minutes=30) - }, response_body["data"]["smartMeterTelemetry"])) - else: - _LOGGER.debug(f"Failed to retrieve smart meter consumption data - device_id: {device_id}; period_from: {period_from}; period_to: {period_to}") + client = self._create_client_session() + url = f'{self._base_url}/v1/graphql/' + + payload = { "query": live_consumption_query.format(device_id=device_id, period_from=period_from.strftime("%Y-%m-%dT%H:%M:%S%z"), period_to=period_to.strftime("%Y-%m-%dT%H:%M:%S%z")) } + headers = { "Authorization": f"JWT {self._graphql_token}" } + async with client.post(url, json=payload, headers=headers) as live_consumption_response: + response_body = await self.__async_read_response__(live_consumption_response, url) + + if (response_body is not None and "data" in response_body and "smartMeterTelemetry" in response_body["data"] and response_body["data"]["smartMeterTelemetry"] is not None and len(response_body["data"]["smartMeterTelemetry"]) > 0): + return list(map(lambda mp: { + "consumption": float(mp["consumptionDelta"]) / 1000 if "consumptionDelta" in mp and mp["consumptionDelta"] is not None else 0, + "demand": float(mp["demand"]) if "demand" in mp and mp["demand"] is not None else None, + "start": parse_datetime(mp["readAt"]), + "end": parse_datetime(mp["readAt"]) + timedelta(minutes=30) + }, response_body["data"]["smartMeterTelemetry"])) + else: + _LOGGER.debug(f"Failed to retrieve smart meter consumption data - device_id: {device_id}; period_from: {period_from}; period_to: {period_to}") except TimeoutError: _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') @@ -641,21 +710,21 @@ async def async_get_electricity_standard_rates(self, product_code, tariff_code, results = [] try: - async with aiohttp.ClientSession(timeout=self._timeout, headers=self._default_headers) as client: - auth = aiohttp.BasicAuth(self._api_key, '') - page = 1 - has_more_rates = True - while has_more_rates: - url = f'{self._base_url}/v1/products/{product_code}/electricity-tariffs/{tariff_code}/standard-unit-rates?period_from={period_from.strftime("%Y-%m-%dT%H:%M:%SZ")}&period_to={period_to.strftime("%Y-%m-%dT%H:%M:%SZ")}&page={page}' - async with client.get(url, auth=auth) as response: - data = await self.__async_read_response__(response, url) - if data is None: - return None - else: - results = results + rates_to_thirty_minute_increments(data, period_from, period_to, tariff_code, self._electricity_price_cap) - has_more_rates = "next" in data and data["next"] is not None - if has_more_rates: - page = page + 1 + client = self._create_client_session() + auth = aiohttp.BasicAuth(self._api_key, '') + page = 1 + has_more_rates = True + while has_more_rates: + url = f'{self._base_url}/v1/products/{product_code}/electricity-tariffs/{tariff_code}/standard-unit-rates?period_from={period_from.strftime("%Y-%m-%dT%H:%M:%SZ")}&period_to={period_to.strftime("%Y-%m-%dT%H:%M:%SZ")}&page={page}' + async with client.get(url, auth=auth) as response: + data = await self.__async_read_response__(response, url) + if data is None: + return None + else: + results = results + rates_to_thirty_minute_increments(data, period_from, period_to, tariff_code, self._electricity_price_cap) + has_more_rates = "next" in data and data["next"] is not None + if has_more_rates: + page = page + 1 except TimeoutError: _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') @@ -669,31 +738,31 @@ async def async_get_electricity_day_night_rates(self, product_code, tariff_code, results = [] try: - async with aiohttp.ClientSession(timeout=self._timeout, headers=self._default_headers) as client: - auth = aiohttp.BasicAuth(self._api_key, '') - url = f'{self._base_url}/v1/products/{product_code}/electricity-tariffs/{tariff_code}/day-unit-rates?period_from={period_from.strftime("%Y-%m-%dT%H:%M:%SZ")}&period_to={period_to.strftime("%Y-%m-%dT%H:%M:%SZ")}' - async with client.get(url, auth=auth) as response: - data = await self.__async_read_response__(response, url) - if data is None: - return None - else: - # Normalise the rates to be in 30 minute increments and remove any rates that fall outside of our day period - day_rates = rates_to_thirty_minute_increments(data, period_from, period_to, tariff_code, self._electricity_price_cap) - for rate in day_rates: - if (self.__is_night_rate(rate, is_smart_meter)) == False: - results.append(rate) + client = self._create_client_session() + auth = aiohttp.BasicAuth(self._api_key, '') + url = f'{self._base_url}/v1/products/{product_code}/electricity-tariffs/{tariff_code}/day-unit-rates?period_from={period_from.strftime("%Y-%m-%dT%H:%M:%SZ")}&period_to={period_to.strftime("%Y-%m-%dT%H:%M:%SZ")}' + async with client.get(url, auth=auth) as response: + data = await self.__async_read_response__(response, url) + if data is None: + return None + else: + # Normalise the rates to be in 30 minute increments and remove any rates that fall outside of our day period + day_rates = rates_to_thirty_minute_increments(data, period_from, period_to, tariff_code, self._electricity_price_cap) + for rate in day_rates: + if (self.__is_night_rate(rate, is_smart_meter)) == False: + results.append(rate) - url = f'{self._base_url}/v1/products/{product_code}/electricity-tariffs/{tariff_code}/night-unit-rates?period_from={period_from.strftime("%Y-%m-%dT%H:%M:%SZ")}&period_to={period_to.strftime("%Y-%m-%dT%H:%M:%SZ")}' - async with client.get(url, auth=auth) as response: - data = await self.__async_read_response__(response, url) - if data is None: - return None + url = f'{self._base_url}/v1/products/{product_code}/electricity-tariffs/{tariff_code}/night-unit-rates?period_from={period_from.strftime("%Y-%m-%dT%H:%M:%SZ")}&period_to={period_to.strftime("%Y-%m-%dT%H:%M:%SZ")}' + async with client.get(url, auth=auth) as response: + data = await self.__async_read_response__(response, url) + if data is None: + return None - # Normalise the rates to be in 30 minute increments and remove any rates that fall outside of our night period - night_rates = rates_to_thirty_minute_increments(data, period_from, period_to, tariff_code, self._electricity_price_cap) - for rate in night_rates: - if (self.__is_night_rate(rate, is_smart_meter)) == True: - results.append(rate) + # Normalise the rates to be in 30 minute increments and remove any rates that fall outside of our night period + night_rates = rates_to_thirty_minute_increments(data, period_from, period_to, tariff_code, self._electricity_price_cap) + for rate in night_rates: + if (self.__is_night_rate(rate, is_smart_meter)) == True: + results.append(rate) except TimeoutError: _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') raise TimeoutException() @@ -717,31 +786,44 @@ async def async_get_electricity_rates(self, tariff_code: str, is_smart_meter: bo else: return await self.async_get_electricity_day_night_rates(product_code, tariff_code, is_smart_meter, period_from, period_to) - async def async_get_electricity_consumption(self, mpan, serial_number, period_from, period_to): + async def async_get_electricity_consumption(self, mpan, serial_number, period_from, period_to, page_size: int | None = None): """Get the current electricity consumption""" try: - async with aiohttp.ClientSession(timeout=self._timeout, headers=self._default_headers) as client: - auth = aiohttp.BasicAuth(self._api_key, '') - url = f'{self._base_url}/v1/electricity-meter-points/{mpan}/meters/{serial_number}/consumption?period_from={period_from.strftime("%Y-%m-%dT%H:%M:%SZ")}&period_to={period_to.strftime("%Y-%m-%dT%H:%M:%SZ")}' - async with client.get(url, auth=auth) as response: - - data = await self.__async_read_response__(response, url) - if (data is not None and "results" in data): - data = data["results"] - results = [] - for item in data: - item = self.__process_consumption(item) - - # For some reason, the end point returns slightly more data than we requested, so we need to filter out - # the results - if as_utc(item["start"]) >= period_from and as_utc(item["end"]) <= period_to: - results.append(item) - - results.sort(key=self.__get_interval_end) - return results + client = self._create_client_session() + auth = aiohttp.BasicAuth(self._api_key, '') + + query_params = [] + if period_from is not None: + query_params.append(f'period_from={period_from.strftime("%Y-%m-%dT%H:%M:%SZ")}') + + if period_to is not None: + query_params.append(f'period_to={period_to.strftime("%Y-%m-%dT%H:%M:%SZ")}') + + if page_size is not None: + query_params.append(f'page_size={page_size}') + + query_string = '&'.join(query_params) + + url = f"{self._base_url}/v1/electricity-meter-points/{mpan}/meters/{serial_number}/consumption{f'?{query_string}' if len(query_string) > 0 else ''}" + async with client.get(url, auth=auth) as response: + + data = await self.__async_read_response__(response, url) + if (data is not None and "results" in data): + data = data["results"] + results = [] + for item in data: + item = self.__process_consumption(item) + + # For some reason, the end point returns slightly more data than we requested, so we need to filter out + # the results + if (period_from is None or as_utc(item["start"]) >= period_from) and (period_to is None or as_utc(item["end"]) <= period_to): + results.append(item) - return None + results.sort(key=self.__get_interval_end) + return results + + return None except TimeoutError: _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') @@ -758,15 +840,15 @@ async def async_get_gas_rates(self, tariff_code, period_from, period_to): results = [] try: - async with aiohttp.ClientSession(timeout=self._timeout, headers=self._default_headers) as client: - auth = aiohttp.BasicAuth(self._api_key, '') - url = f'{self._base_url}/v1/products/{product_code}/gas-tariffs/{tariff_code}/standard-unit-rates?period_from={period_from.strftime("%Y-%m-%dT%H:%M:%SZ")}&period_to={period_to.strftime("%Y-%m-%dT%H:%M:%SZ")}' - async with client.get(url, auth=auth) as response: - data = await self.__async_read_response__(response, url) - if data is None: - return None - else: - results = rates_to_thirty_minute_increments(data, period_from, period_to, tariff_code, self._gas_price_cap) + client = self._create_client_session() + auth = aiohttp.BasicAuth(self._api_key, '') + url = f'{self._base_url}/v1/products/{product_code}/gas-tariffs/{tariff_code}/standard-unit-rates?period_from={period_from.strftime("%Y-%m-%dT%H:%M:%SZ")}&period_to={period_to.strftime("%Y-%m-%dT%H:%M:%SZ")}' + async with client.get(url, auth=auth) as response: + data = await self.__async_read_response__(response, url) + if data is None: + return None + else: + results = rates_to_thirty_minute_increments(data, period_from, period_to, tariff_code, self._gas_price_cap) return results @@ -774,30 +856,43 @@ async def async_get_gas_rates(self, tariff_code, period_from, period_to): _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') raise TimeoutException() - async def async_get_gas_consumption(self, mprn, serial_number, period_from, period_to): + async def async_get_gas_consumption(self, mprn, serial_number, period_from, period_to, page_size: int | None = None): """Get the current gas rates""" try: - async with aiohttp.ClientSession(timeout=self._timeout, headers=self._default_headers) as client: - auth = aiohttp.BasicAuth(self._api_key, '') - url = f'{self._base_url}/v1/gas-meter-points/{mprn}/meters/{serial_number}/consumption?period_from={period_from.strftime("%Y-%m-%dT%H:%M:%SZ")}&period_to={period_to.strftime("%Y-%m-%dT%H:%M:%SZ")}' - async with client.get(url, auth=auth) as response: - data = await self.__async_read_response__(response, url) - if (data is not None and "results" in data): - data = data["results"] - results = [] - for item in data: - item = self.__process_consumption(item) - - # For some reason, the end point returns slightly more data than we requested, so we need to filter out - # the results - if as_utc(item["start"]) >= period_from and as_utc(item["end"]) <= period_to: - results.append(item) - - results.sort(key=self.__get_interval_end) - return results + client = self._create_client_session() + auth = aiohttp.BasicAuth(self._api_key, '') + + query_params = [] + if period_from is not None: + query_params.append(f'period_from={period_from.strftime("%Y-%m-%dT%H:%M:%SZ")}') + + if period_to is not None: + query_params.append(f'period_to={period_to.strftime("%Y-%m-%dT%H:%M:%SZ")}') + + if page_size is not None: + query_params.append(f'page_size={page_size}') + + query_string = '&'.join(query_params) + + url = f"{self._base_url}/v1/gas-meter-points/{mprn}/meters/{serial_number}/consumption{f'?{query_string}' if len(query_string) > 0 else ''}" + async with client.get(url, auth=auth) as response: + data = await self.__async_read_response__(response, url) + if (data is not None and "results" in data): + data = data["results"] + results = [] + for item in data: + item = self.__process_consumption(item) + + # For some reason, the end point returns slightly more data than we requested, so we need to filter out + # the results + if (period_from is None or as_utc(item["start"]) >= period_from) and (period_to is None or as_utc(item["end"]) <= period_to): + results.append(item) - return None + results.sort(key=self.__get_interval_end) + return results + + return None except TimeoutError: _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') raise TimeoutException() @@ -806,11 +901,11 @@ async def async_get_product(self, product_code): """Get all products""" try: - async with aiohttp.ClientSession(timeout=self._timeout, headers=self._default_headers) as client: - auth = aiohttp.BasicAuth(self._api_key, '') - url = f'{self._base_url}/v1/products/{product_code}' - async with client.get(url, auth=auth) as response: - return await self.__async_read_response__(response, url) + client = self._create_client_session() + auth = aiohttp.BasicAuth(self._api_key, '') + url = f'{self._base_url}/v1/products/{product_code}' + async with client.get(url, auth=auth) as response: + return await self.__async_read_response__(response, url) except TimeoutError: _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') raise TimeoutException() @@ -826,17 +921,17 @@ async def async_get_electricity_standing_charge(self, tariff_code, period_from, result = None try: - async with aiohttp.ClientSession(timeout=self._timeout, headers=self._default_headers) as client: - auth = aiohttp.BasicAuth(self._api_key, '') - url = f'{self._base_url}/v1/products/{product_code}/electricity-tariffs/{tariff_code}/standing-charges?period_from={period_from.strftime("%Y-%m-%dT%H:%M:%SZ")}&period_to={period_to.strftime("%Y-%m-%dT%H:%M:%SZ")}' - async with client.get(url, auth=auth) as response: - data = await self.__async_read_response__(response, url) - if (data is not None and "results" in data and len(data["results"]) > 0): - result = { - "start": parse_datetime(data["results"][0]["valid_from"]) if "valid_from" in data["results"][0] and data["results"][0]["valid_from"] is not None else None, - "end": parse_datetime(data["results"][0]["valid_to"]) if "valid_to" in data["results"][0] and data["results"][0]["valid_to"] is not None else None, - "value_inc_vat": float(data["results"][0]["value_inc_vat"]) - } + client = self._create_client_session() + auth = aiohttp.BasicAuth(self._api_key, '') + url = f'{self._base_url}/v1/products/{product_code}/electricity-tariffs/{tariff_code}/standing-charges?period_from={period_from.strftime("%Y-%m-%dT%H:%M:%SZ")}&period_to={period_to.strftime("%Y-%m-%dT%H:%M:%SZ")}' + async with client.get(url, auth=auth) as response: + data = await self.__async_read_response__(response, url) + if (data is not None and "results" in data and len(data["results"]) > 0): + result = { + "start": parse_datetime(data["results"][0]["valid_from"]) if "valid_from" in data["results"][0] and data["results"][0]["valid_from"] is not None else None, + "end": parse_datetime(data["results"][0]["valid_to"]) if "valid_to" in data["results"][0] and data["results"][0]["valid_to"] is not None else None, + "value_inc_vat": float(data["results"][0]["value_inc_vat"]) + } return result except TimeoutError: @@ -854,17 +949,17 @@ async def async_get_gas_standing_charge(self, tariff_code, period_from, period_t result = None try: - async with aiohttp.ClientSession(timeout=self._timeout, headers=self._default_headers) as client: - auth = aiohttp.BasicAuth(self._api_key, '') - url = f'{self._base_url}/v1/products/{product_code}/gas-tariffs/{tariff_code}/standing-charges?period_from={period_from.strftime("%Y-%m-%dT%H:%M:%SZ")}&period_to={period_to.strftime("%Y-%m-%dT%H:%M:%SZ")}' - async with client.get(url, auth=auth) as response: - data = await self.__async_read_response__(response, url) - if (data is not None and "results" in data and len(data["results"]) > 0): - result = { - "start": parse_datetime(data["results"][0]["valid_from"]) if "valid_from" in data["results"][0] and data["results"][0]["valid_from"] is not None else None, - "end": parse_datetime(data["results"][0]["valid_to"]) if "valid_to" in data["results"][0] and data["results"][0]["valid_to"] is not None else None, - "value_inc_vat": float(data["results"][0]["value_inc_vat"]) - } + client = self._create_client_session() + auth = aiohttp.BasicAuth(self._api_key, '') + url = f'{self._base_url}/v1/products/{product_code}/gas-tariffs/{tariff_code}/standing-charges?period_from={period_from.strftime("%Y-%m-%dT%H:%M:%SZ")}&period_to={period_to.strftime("%Y-%m-%dT%H:%M:%SZ")}' + async with client.get(url, auth=auth) as response: + data = await self.__async_read_response__(response, url) + if (data is not None and "results" in data and len(data["results"]) > 0): + result = { + "start": parse_datetime(data["results"][0]["valid_from"]) if "valid_from" in data["results"][0] and data["results"][0]["valid_from"] is not None else None, + "end": parse_datetime(data["results"][0]["valid_to"]) if "valid_to" in data["results"][0] and data["results"][0]["valid_to"] is not None else None, + "value_inc_vat": float(data["results"][0]["value_inc_vat"]) + } return result except TimeoutError: @@ -876,43 +971,42 @@ async def async_get_intelligent_dispatches(self, account_id: str): await self.async_refresh_token() try: - async with aiohttp.ClientSession(timeout=self._timeout, headers=self._default_headers) as client: - url = f'{self._base_url}/v1/graphql/' - # Get account response - payload = { "query": intelligent_dispatches_query.format(account_id=account_id) } - headers = { "Authorization": f"JWT {self._graphql_token}" } - async with client.post(url, json=payload, headers=headers) as response: - response_body = await self.__async_read_response__(response, url) - _LOGGER.debug(f'async_get_intelligent_dispatches: {response_body}') - - if (response_body is not None and "data" in response_body): - return IntelligentDispatches( - list(map(lambda ev: IntelligentDispatchItem( - as_utc(parse_datetime(ev["startDt"])), - as_utc(parse_datetime(ev["endDt"])), - float(ev["delta"]) if "delta" in ev and ev["delta"] is not None else None, - ev["meta"]["source"] if "meta" in ev and "source" in ev["meta"] else None, - ev["meta"]["location"] if "meta" in ev and "location" in ev["meta"] else None, - ), response_body["data"]["plannedDispatches"] - if "plannedDispatches" in response_body["data"] and response_body["data"]["plannedDispatches"] is not None - else []) - ), - list(map(lambda ev: IntelligentDispatchItem( - as_utc(parse_datetime(ev["startDt"])), - as_utc(parse_datetime(ev["endDt"])), - float(ev["delta"]) if "delta" in ev and ev["delta"] is not None else None, - ev["meta"]["source"] if "meta" in ev and "source" in ev["meta"] else None, - ev["meta"]["location"] if "meta" in ev and "location" in ev["meta"] else None, - ), response_body["data"]["completedDispatches"] - if "completedDispatches" in response_body["data"] and response_body["data"]["completedDispatches"] is not None - else []) - ) + client = self._create_client_session() + url = f'{self._base_url}/v1/graphql/' + # Get account response + payload = { "query": intelligent_dispatches_query.format(account_id=account_id) } + headers = { "Authorization": f"JWT {self._graphql_token}" } + async with client.post(url, json=payload, headers=headers) as response: + response_body = await self.__async_read_response__(response, url) + _LOGGER.debug(f'async_get_intelligent_dispatches: {response_body}') + + if (response_body is not None and "data" in response_body): + return IntelligentDispatches( + list(map(lambda ev: IntelligentDispatchItem( + as_utc(parse_datetime(ev["startDt"])), + as_utc(parse_datetime(ev["endDt"])), + float(ev["delta"]) if "delta" in ev and ev["delta"] is not None else None, + ev["meta"]["source"] if "meta" in ev and "source" in ev["meta"] else None, + ev["meta"]["location"] if "meta" in ev and "location" in ev["meta"] else None, + ), response_body["data"]["plannedDispatches"] + if "plannedDispatches" in response_body["data"] and response_body["data"]["plannedDispatches"] is not None + else []) + ), + list(map(lambda ev: IntelligentDispatchItem( + as_utc(parse_datetime(ev["startDt"])), + as_utc(parse_datetime(ev["endDt"])), + float(ev["delta"]) if "delta" in ev and ev["delta"] is not None else None, + ev["meta"]["source"] if "meta" in ev and "source" in ev["meta"] else None, + ev["meta"]["location"] if "meta" in ev and "location" in ev["meta"] else None, + ), response_body["data"]["completedDispatches"] + if "completedDispatches" in response_body["data"] and response_body["data"]["completedDispatches"] is not None + else []) ) - else: - _LOGGER.error("Failed to retrieve intelligent dispatches") + ) + else: + _LOGGER.error("Failed to retrieve intelligent dispatches") return None - except TimeoutError: _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') raise TimeoutException() @@ -922,36 +1016,36 @@ async def async_get_intelligent_settings(self, account_id: str): await self.async_refresh_token() try: - async with aiohttp.ClientSession(timeout=self._timeout, headers=self._default_headers) as client: - url = f'{self._base_url}/v1/graphql/' - payload = { "query": intelligent_settings_query.format(account_id=account_id) } - headers = { "Authorization": f"JWT {self._graphql_token}" } - async with client.post(url, json=payload, headers=headers) as response: - response_body = await self.__async_read_response__(response, url) - _LOGGER.debug(f'async_get_intelligent_settings: {response_body}') - - _LOGGER.debug(f'Intelligent Settings: {response_body}') - if (response_body is not None and "data" in response_body): - - return IntelligentSettings( - response_body["data"]["registeredKrakenflexDevice"]["suspended"] == False - if "registeredKrakenflexDevice" in response_body["data"] and "suspended" in response_body["data"]["registeredKrakenflexDevice"] - else None, - int(response_body["data"]["vehicleChargingPreferences"]["weekdayTargetSoc"]) - if "vehicleChargingPreferences" in response_body["data"] and "weekdayTargetSoc" in response_body["data"]["vehicleChargingPreferences"] - else None, - int(response_body["data"]["vehicleChargingPreferences"]["weekendTargetSoc"]) - if "vehicleChargingPreferences" in response_body["data"] and "weekendTargetSoc" in response_body["data"]["vehicleChargingPreferences"] - else None, - self.__ready_time_to_time__(response_body["data"]["vehicleChargingPreferences"]["weekdayTargetTime"]) - if "vehicleChargingPreferences" in response_body["data"] and "weekdayTargetTime" in response_body["data"]["vehicleChargingPreferences"] - else None, - self.__ready_time_to_time__(response_body["data"]["vehicleChargingPreferences"]["weekendTargetTime"]) - if "vehicleChargingPreferences" in response_body["data"] and "weekendTargetTime" in response_body["data"]["vehicleChargingPreferences"] - else None - ) - else: - _LOGGER.error("Failed to retrieve intelligent settings") + client = self._create_client_session() + url = f'{self._base_url}/v1/graphql/' + payload = { "query": intelligent_settings_query.format(account_id=account_id) } + headers = { "Authorization": f"JWT {self._graphql_token}" } + async with client.post(url, json=payload, headers=headers) as response: + response_body = await self.__async_read_response__(response, url) + _LOGGER.debug(f'async_get_intelligent_settings: {response_body}') + + _LOGGER.debug(f'Intelligent Settings: {response_body}') + if (response_body is not None and "data" in response_body): + + return IntelligentSettings( + response_body["data"]["registeredKrakenflexDevice"]["suspended"] == False + if "registeredKrakenflexDevice" in response_body["data"] and "suspended" in response_body["data"]["registeredKrakenflexDevice"] + else None, + int(response_body["data"]["vehicleChargingPreferences"]["weekdayTargetSoc"]) + if "vehicleChargingPreferences" in response_body["data"] and "weekdayTargetSoc" in response_body["data"]["vehicleChargingPreferences"] + else None, + int(response_body["data"]["vehicleChargingPreferences"]["weekendTargetSoc"]) + if "vehicleChargingPreferences" in response_body["data"] and "weekendTargetSoc" in response_body["data"]["vehicleChargingPreferences"] + else None, + self.__ready_time_to_time__(response_body["data"]["vehicleChargingPreferences"]["weekdayTargetTime"]) + if "vehicleChargingPreferences" in response_body["data"] and "weekdayTargetTime" in response_body["data"]["vehicleChargingPreferences"] + else None, + self.__ready_time_to_time__(response_body["data"]["vehicleChargingPreferences"]["weekendTargetTime"]) + if "vehicleChargingPreferences" in response_body["data"] and "weekendTargetTime" in response_body["data"]["vehicleChargingPreferences"] + else None + ) + else: + _LOGGER.error("Failed to retrieve intelligent settings") return None @@ -980,20 +1074,20 @@ async def async_update_intelligent_car_target_percentage( settings = await self.async_get_intelligent_settings(account_id) try: - async with aiohttp.ClientSession(timeout=self._timeout, headers=self._default_headers) as client: - url = f'{self._base_url}/v1/graphql/' - payload = { "query": intelligent_settings_mutation.format( - account_id=account_id, - weekday_target_percentage=target_percentage, - weekend_target_percentage=target_percentage, - weekday_target_time=settings.ready_time_weekday.strftime("%H:%M"), - weekend_target_time=settings.ready_time_weekend.strftime("%H:%M") - ) } - - headers = { "Authorization": f"JWT {self._graphql_token}" } - async with client.post(url, json=payload, headers=headers) as response: - response_body = await self.__async_read_response__(response, url) - _LOGGER.debug(f'async_update_intelligent_car_target_percentage: {response_body}') + client = self._create_client_session() + url = f'{self._base_url}/v1/graphql/' + payload = { "query": intelligent_settings_mutation.format( + account_id=account_id, + weekday_target_percentage=target_percentage, + weekend_target_percentage=target_percentage, + weekday_target_time=settings.ready_time_weekday.strftime("%H:%M"), + weekend_target_time=settings.ready_time_weekend.strftime("%H:%M") + ) } + + headers = { "Authorization": f"JWT {self._graphql_token}" } + async with client.post(url, json=payload, headers=headers) as response: + response_body = await self.__async_read_response__(response, url) + _LOGGER.debug(f'async_update_intelligent_car_target_percentage: {response_body}') except TimeoutError: _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') raise TimeoutException() @@ -1008,20 +1102,20 @@ async def async_update_intelligent_car_target_time( settings = await self.async_get_intelligent_settings(account_id) try: - async with aiohttp.ClientSession(timeout=self._timeout, headers=self._default_headers) as client: - url = f'{self._base_url}/v1/graphql/' - payload = { "query": intelligent_settings_mutation.format( - account_id=account_id, - weekday_target_percentage=settings.charge_limit_weekday, - weekend_target_percentage=settings.charge_limit_weekend, - weekday_target_time=target_time.strftime("%H:%M"), - weekend_target_time=target_time.strftime("%H:%M") - ) } - - headers = { "Authorization": f"JWT {self._graphql_token}" } - async with client.post(url, json=payload, headers=headers) as response: - response_body = await self.__async_read_response__(response, url) - _LOGGER.debug(f'async_update_intelligent_car_target_time: {response_body}') + client = self._create_client_session() + url = f'{self._base_url}/v1/graphql/' + payload = { "query": intelligent_settings_mutation.format( + account_id=account_id, + weekday_target_percentage=settings.charge_limit_weekday, + weekend_target_percentage=settings.charge_limit_weekend, + weekday_target_time=target_time.strftime("%H:%M"), + weekend_target_time=target_time.strftime("%H:%M") + ) } + + headers = { "Authorization": f"JWT {self._graphql_token}" } + async with client.post(url, json=payload, headers=headers) as response: + response_body = await self.__async_read_response__(response, url) + _LOGGER.debug(f'async_update_intelligent_car_target_time: {response_body}') except TimeoutError: _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') raise TimeoutException() @@ -1033,16 +1127,16 @@ async def async_turn_on_intelligent_bump_charge( await self.async_refresh_token() try: - async with aiohttp.ClientSession(timeout=self._timeout, headers=self._default_headers) as client: - url = f'{self._base_url}/v1/graphql/' - payload = { "query": intelligent_turn_on_bump_charge_mutation.format( - account_id=account_id, - ) } - - headers = { "Authorization": f"JWT {self._graphql_token}" } - async with client.post(url, json=payload, headers=headers) as response: - response_body = await self.__async_read_response__(response, url) - _LOGGER.debug(f'async_turn_on_intelligent_bump_charge: {response_body}') + client = self._create_client_session() + url = f'{self._base_url}/v1/graphql/' + payload = { "query": intelligent_turn_on_bump_charge_mutation.format( + account_id=account_id, + ) } + + headers = { "Authorization": f"JWT {self._graphql_token}" } + async with client.post(url, json=payload, headers=headers) as response: + response_body = await self.__async_read_response__(response, url) + _LOGGER.debug(f'async_turn_on_intelligent_bump_charge: {response_body}') except TimeoutError: _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') raise TimeoutException() @@ -1054,16 +1148,16 @@ async def async_turn_off_intelligent_bump_charge( await self.async_refresh_token() try: - async with aiohttp.ClientSession(timeout=self._timeout, headers=self._default_headers) as client: - url = f'{self._base_url}/v1/graphql/' - payload = { "query": intelligent_turn_off_bump_charge_mutation.format( - account_id=account_id, - ) } - - headers = { "Authorization": f"JWT {self._graphql_token}" } - async with client.post(url, json=payload, headers=headers) as response: - response_body = await self.__async_read_response__(response, url) - _LOGGER.debug(f'async_turn_off_intelligent_bump_charge: {response_body}') + client = self._create_client_session() + url = f'{self._base_url}/v1/graphql/' + payload = { "query": intelligent_turn_off_bump_charge_mutation.format( + account_id=account_id, + ) } + + headers = { "Authorization": f"JWT {self._graphql_token}" } + async with client.post(url, json=payload, headers=headers) as response: + response_body = await self.__async_read_response__(response, url) + _LOGGER.debug(f'async_turn_off_intelligent_bump_charge: {response_body}') except TimeoutError: _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') raise TimeoutException() @@ -1075,16 +1169,16 @@ async def async_turn_on_intelligent_smart_charge( await self.async_refresh_token() try: - async with aiohttp.ClientSession(timeout=self._timeout, headers=self._default_headers) as client: - url = f'{self._base_url}/v1/graphql/' - payload = { "query": intelligent_turn_on_smart_charge_mutation.format( - account_id=account_id, - ) } - - headers = { "Authorization": f"JWT {self._graphql_token}" } - async with client.post(url, json=payload, headers=headers) as response: - response_body = await self.__async_read_response__(response, url) - _LOGGER.debug(f'async_turn_on_intelligent_smart_charge: {response_body}') + client = self._create_client_session() + url = f'{self._base_url}/v1/graphql/' + payload = { "query": intelligent_turn_on_smart_charge_mutation.format( + account_id=account_id, + ) } + + headers = { "Authorization": f"JWT {self._graphql_token}" } + async with client.post(url, json=payload, headers=headers) as response: + response_body = await self.__async_read_response__(response, url) + _LOGGER.debug(f'async_turn_on_intelligent_smart_charge: {response_body}') except TimeoutError: _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') raise TimeoutException() @@ -1096,49 +1190,49 @@ async def async_turn_off_intelligent_smart_charge( await self.async_refresh_token() try: - async with aiohttp.ClientSession(timeout=self._timeout, headers=self._default_headers) as client: - url = f'{self._base_url}/v1/graphql/' - payload = { "query": intelligent_turn_off_smart_charge_mutation.format( - account_id=account_id, - ) } - - headers = { "Authorization": f"JWT {self._graphql_token}" } - async with client.post(url, json=payload, headers=headers) as response: - response_body = await self.__async_read_response__(response, url) - _LOGGER.debug(f'async_turn_off_intelligent_smart_charge: {response_body}') + client = self._create_client_session() + url = f'{self._base_url}/v1/graphql/' + payload = { "query": intelligent_turn_off_smart_charge_mutation.format( + account_id=account_id, + ) } + + headers = { "Authorization": f"JWT {self._graphql_token}" } + async with client.post(url, json=payload, headers=headers) as response: + response_body = await self.__async_read_response__(response, url) + _LOGGER.debug(f'async_turn_off_intelligent_smart_charge: {response_body}') except TimeoutError: _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') raise TimeoutException() - async def async_get_intelligent_device(self, account_id: str): + async def async_get_intelligent_device(self, account_id: str) -> IntelligentDevice: """Get the user's intelligent dispatches""" await self.async_refresh_token() try: - async with aiohttp.ClientSession(timeout=self._timeout, headers=self._default_headers) as client: - url = f'{self._base_url}/v1/graphql/' - payload = { "query": intelligent_device_query.format(account_id=account_id) } - headers = { "Authorization": f"JWT {self._graphql_token}" } - async with client.post(url, json=payload, headers=headers) as response: - response_body = await self.__async_read_response__(response, url) - _LOGGER.debug(f'async_get_intelligent_device: {response_body}') - - if (response_body is not None and "data" in response_body and - "registeredKrakenflexDevice" in response_body["data"]): - device = response_body["data"]["registeredKrakenflexDevice"] - return { - "krakenflexDeviceId": device["krakenflexDeviceId"], - "provider": device["provider"], - "vehicleMake": device["vehicleMake"], - "vehicleModel": device["vehicleModel"], - "vehicleBatterySizeInKwh": float(device["vehicleBatterySizeInKwh"]) if "vehicleBatterySizeInKwh" in device and device["vehicleBatterySizeInKwh"] is not None else None, - "chargePointMake": device["chargePointMake"], - "chargePointModel": device["chargePointModel"], - "chargePointPowerInKw": float(device["chargePointPowerInKw"]) if "chargePointPowerInKw" in device and device["chargePointPowerInKw"] is not None else None, - - } - else: - _LOGGER.error("Failed to retrieve intelligent device") + client = self._create_client_session() + url = f'{self._base_url}/v1/graphql/' + payload = { "query": intelligent_device_query.format(account_id=account_id) } + headers = { "Authorization": f"JWT {self._graphql_token}" } + async with client.post(url, json=payload, headers=headers) as response: + response_body = await self.__async_read_response__(response, url) + _LOGGER.debug(f'async_get_intelligent_device: {response_body}') + + if (response_body is not None and "data" in response_body and + "registeredKrakenflexDevice" in response_body["data"]): + device = response_body["data"]["registeredKrakenflexDevice"] + if device["krakenflexDeviceId"] is not None: + return IntelligentDevice( + device["krakenflexDeviceId"], + device["provider"], + device["vehicleMake"], + device["vehicleModel"], + float(device["vehicleBatterySizeInKwh"]) if "vehicleBatterySizeInKwh" in device and device["vehicleBatterySizeInKwh"] is not None else None, + device["chargePointMake"], + device["chargePointModel"], + float(device["chargePointPowerInKw"]) if "chargePointPowerInKw" in device and device["chargePointPowerInKw"] is not None else None + ) + else: + _LOGGER.error("Failed to retrieve intelligent device") return None @@ -1151,24 +1245,24 @@ async def async_get_wheel_of_fortune_spins(self, account_id: str) -> WheelOfFort await self.async_refresh_token() try: - async with aiohttp.ClientSession(timeout=self._timeout, headers=self._default_headers) as client: - url = f'{self._base_url}/v1/graphql/' - payload = { "query": wheel_of_fortune_query.format(account_id=account_id) } - headers = { "Authorization": f"JWT {self._graphql_token}" } - async with client.post(url, json=payload, headers=headers) as response: - response_body = await self.__async_read_response__(response, url) - _LOGGER.debug(f'async_get_wheel_of_fortune_spins: {response_body}') - - if (response_body is not None and "data" in response_body and - "wheelOfFortuneSpins" in response_body["data"]): - - spins = response_body["data"]["wheelOfFortuneSpins"] - return WheelOfFortuneSpinsResponse( - int(spins["electricity"]["remainingSpinsThisMonth"]) if "electricity" in spins and "remainingSpinsThisMonth" in spins["electricity"] else 0, - int(spins["gas"]["remainingSpinsThisMonth"]) if "gas" in spins and "remainingSpinsThisMonth" in spins["gas"] else 0 - ) - else: - _LOGGER.error("Failed to retrieve wheel of fortune spins") + client = self._create_client_session() + url = f'{self._base_url}/v1/graphql/' + payload = { "query": wheel_of_fortune_query.format(account_id=account_id) } + headers = { "Authorization": f"JWT {self._graphql_token}" } + async with client.post(url, json=payload, headers=headers) as response: + response_body = await self.__async_read_response__(response, url) + _LOGGER.debug(f'async_get_wheel_of_fortune_spins: {response_body}') + + if (response_body is not None and "data" in response_body and + "wheelOfFortuneSpins" in response_body["data"]): + + spins = response_body["data"]["wheelOfFortuneSpins"] + return WheelOfFortuneSpinsResponse( + int(spins["electricity"]["remainingSpinsThisMonth"]) if "electricity" in spins and "remainingSpinsThisMonth" in spins["electricity"] else 0, + int(spins["gas"]["remainingSpinsThisMonth"]) if "gas" in spins and "remainingSpinsThisMonth" in spins["gas"] else 0 + ) + else: + _LOGGER.error("Failed to retrieve wheel of fortune spins") return None @@ -1181,23 +1275,23 @@ async def async_spin_wheel_of_fortune(self, account_id: str, is_electricity: boo await self.async_refresh_token() try: - async with aiohttp.ClientSession(timeout=self._timeout, headers=self._default_headers) as client: - url = f'{self._base_url}/v1/graphql/' - payload = { "query": wheel_of_fortune_mutation.format(account_id=account_id, supply_type="ELECTRICITY" if is_electricity == True else "GAS") } - headers = { "Authorization": f"JWT {self._graphql_token}" } - async with client.post(url, json=payload, headers=headers) as response: - response_body = await self.__async_read_response__(response, url) - _LOGGER.debug(f'async_spin_wheel_of_fortune: {response_body}') - - if (response_body is not None and - "data" in response_body and - "spinWheelOfFortune" in response_body["data"] and - "spinResult" in response_body["data"]["spinWheelOfFortune"] and - "prizeAmount" in response_body["data"]["spinWheelOfFortune"]["spinResult"]): - - return int(response_body["data"]["spinWheelOfFortune"]["spinResult"]["prizeAmount"]) - else: - _LOGGER.error("Failed to spin wheel of fortune") + client = self._create_client_session() + url = f'{self._base_url}/v1/graphql/' + payload = { "query": wheel_of_fortune_mutation.format(account_id=account_id, supply_type="ELECTRICITY" if is_electricity == True else "GAS") } + headers = { "Authorization": f"JWT {self._graphql_token}" } + async with client.post(url, json=payload, headers=headers) as response: + response_body = await self.__async_read_response__(response, url) + _LOGGER.debug(f'async_spin_wheel_of_fortune: {response_body}') + + if (response_body is not None and + "data" in response_body and + "spinWheelOfFortune" in response_body["data"] and + "spinResult" in response_body["data"]["spinWheelOfFortune"] and + "prizeAmount" in response_body["data"]["spinWheelOfFortune"]["spinResult"]): + + return int(response_body["data"]["spinWheelOfFortune"]["spinResult"]["prizeAmount"]) + else: + _LOGGER.error("Failed to spin wheel of fortune") return None except TimeoutError: diff --git a/custom_components/octopus_energy/binary_sensor.py b/custom_components/octopus_energy/binary_sensor.py index 447457dff..1783fcf48 100644 --- a/custom_components/octopus_energy/binary_sensor.py +++ b/custom_components/octopus_energy/binary_sensor.py @@ -9,14 +9,17 @@ from .octoplus.saving_sessions import OctopusEnergySavingSessions from .target_rates.target_rate import OctopusEnergyTargetRate from .intelligent.dispatching import OctopusEnergyIntelligentDispatching +from .greenness_forecast.highlighted import OctopusEnergyGreennessForecastHighlighted from .utils import get_active_tariff_code from .intelligent import get_intelligent_features +from .api_client.intelligent_device import IntelligentDevice from .const import ( CONFIG_KIND, CONFIG_KIND_ACCOUNT, CONFIG_KIND_TARGET_RATE, CONFIG_ACCOUNT_ID, + DATA_GREENNESS_FORECAST_COORDINATOR, DATA_INTELLIGENT_DEVICE, DATA_INTELLIGENT_DISPATCHES_COORDINATOR, DATA_INTELLIGENT_MPAN, @@ -46,15 +49,18 @@ async def async_setup_entry(hass, entry, async_add_entities): vol.All( vol.Schema( { - vol.Required("target_hours"): str, + vol.Optional("target_hours"): str, vol.Optional("target_start_time"): str, vol.Optional("target_end_time"): str, vol.Optional("target_offset"): str, + vol.Optional("target_minimum_rate"): str, + vol.Optional("target_maximum_rate"): str, + vol.Optional("target_weighting"): str, }, extra=vol.ALLOW_EXTRA, ), cv.has_at_least_one_key( - "target_hours", "target_start_time", "target_end_time", "target_offset" + "target_hours", "target_start_time", "target_end_time", "target_offset", "target_minimum_rate", "target_maximum_rate" ), ), "async_update_config", @@ -74,9 +80,13 @@ async def async_setup_main_sensors(hass, entry, async_add_entities): account_info = account_result.account if account_result is not None else None saving_session_coordinator = hass.data[DOMAIN][account_id][DATA_SAVING_SESSIONS_COORDINATOR] + greenness_forecast_coordinator = hass.data[DOMAIN][account_id][DATA_GREENNESS_FORECAST_COORDINATOR] now = utcnow() - entities = [OctopusEnergySavingSessions(hass, saving_session_coordinator, account_id)] + entities = [ + OctopusEnergySavingSessions(hass, saving_session_coordinator, account_id), + OctopusEnergyGreennessForecastHighlighted(hass, greenness_forecast_coordinator, account_id) + ] if len(account_info["electricity_meter_points"]) > 0: for point in account_info["electricity_meter_points"]: @@ -90,11 +100,11 @@ async def async_setup_main_sensors(hass, entry, async_add_entities): entities.append(OctopusEnergyElectricityOffPeak(hass, electricity_rate_coordinator, meter, point)) - intelligent_device = hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DEVICE] if DATA_INTELLIGENT_DEVICE in hass.data[DOMAIN][account_id] else None + intelligent_device: IntelligentDevice = hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DEVICE] if DATA_INTELLIGENT_DEVICE in hass.data[DOMAIN][account_id] else None intelligent_mpan = hass.data[DOMAIN][account_id][DATA_INTELLIGENT_MPAN] if DATA_INTELLIGENT_MPAN in hass.data[DOMAIN][account_id] else None intelligent_serial_number = hass.data[DOMAIN][account_id][DATA_INTELLIGENT_SERIAL_NUMBER] if DATA_INTELLIGENT_SERIAL_NUMBER in hass.data[DOMAIN][account_id] else None if intelligent_device is not None and intelligent_mpan is not None and intelligent_serial_number is not None: - intelligent_features = get_intelligent_features(intelligent_device["provider"]) + intelligent_features = get_intelligent_features(intelligent_device.provider) coordinator = hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DISPATCHES_COORDINATOR] electricity_rate_coordinator = hass.data[DOMAIN][account_id][DATA_ELECTRICITY_RATES_COORDINATOR_KEY.format(intelligent_mpan, intelligent_serial_number)] entities.append(OctopusEnergyIntelligentDispatching(hass, coordinator, electricity_rate_coordinator, intelligent_mpan, intelligent_device, account_id, intelligent_features.planned_dispatches_supported)) diff --git a/custom_components/octopus_energy/config/__init__.py b/custom_components/octopus_energy/config/__init__.py index e69de29bb..a9136fe72 100644 --- a/custom_components/octopus_energy/config/__init__.py +++ b/custom_components/octopus_energy/config/__init__.py @@ -0,0 +1,11 @@ +from ..utils import get_active_tariff_code + +def get_meter_tariffs(account_info, now): + meters = {} + if account_info is not None and len(account_info["electricity_meter_points"]) > 0: + for point in account_info["electricity_meter_points"]: + active_tariff_code = get_active_tariff_code(now, point["agreements"]) + if active_tariff_code is not None: + meters[point["mpan"]] = active_tariff_code + + return meters \ No newline at end of file diff --git a/custom_components/octopus_energy/config/cost_tracker.py b/custom_components/octopus_energy/config/cost_tracker.py index d399119be..af3aa2d8d 100644 --- a/custom_components/octopus_energy/config/cost_tracker.py +++ b/custom_components/octopus_energy/config/cost_tracker.py @@ -1,7 +1,12 @@ import re +from . import get_meter_tariffs + from ..const import ( + CONFIG_COST_MONTH_DAY_RESET, + CONFIG_COST_MPAN, CONFIG_COST_NAME, + CONFIG_COST_WEEKDAY_RESET, REGEX_ENTITY_NAME ) @@ -20,11 +25,30 @@ def merge_cost_tracker_config(data: dict, options: dict, updated_config: dict = return config -def validate_cost_tracker_config(data): +def validate_cost_tracker_config(data, account_info, now): errors = {} matches = re.search(REGEX_ENTITY_NAME, data[CONFIG_COST_NAME]) if matches is None: errors[CONFIG_COST_NAME] = "invalid_target_name" + meter_tariffs = get_meter_tariffs(account_info, now) + if (data[CONFIG_COST_MPAN] not in meter_tariffs): + errors[CONFIG_COST_MPAN] = "invalid_mpan" + + # For some reason int type isn't working properly - reporting user input malformed + if CONFIG_COST_WEEKDAY_RESET in data: + if isinstance(data[CONFIG_COST_WEEKDAY_RESET], int) == False: + matches = re.search("^[0-9]+$", data[CONFIG_COST_WEEKDAY_RESET]) + if matches is None: + errors[CONFIG_COST_WEEKDAY_RESET] = "invalid_week_day" + else: + data[CONFIG_COST_WEEKDAY_RESET] = int(data[CONFIG_COST_WEEKDAY_RESET]) + + if (data[CONFIG_COST_WEEKDAY_RESET] < 0 or data[CONFIG_COST_WEEKDAY_RESET] > 6): + errors[CONFIG_COST_WEEKDAY_RESET] = "invalid_week_day" + + if (CONFIG_COST_MONTH_DAY_RESET in data and (data[CONFIG_COST_MONTH_DAY_RESET] < 1 or data[CONFIG_COST_MONTH_DAY_RESET] > 28)): + errors[CONFIG_COST_MONTH_DAY_RESET] = "invalid_month_day" + return errors \ No newline at end of file diff --git a/custom_components/octopus_energy/config/main.py b/custom_components/octopus_energy/config/main.py index 73c8f7c6e..4ad0eb4c9 100644 --- a/custom_components/octopus_energy/config/main.py +++ b/custom_components/octopus_energy/config/main.py @@ -55,9 +55,17 @@ def merge_main_config(data: dict, options: dict, updated_config: dict = None): return config -async def async_validate_main_config(data): +async def async_validate_main_config(data, account_ids = []): errors = {} + if data[CONFIG_ACCOUNT_ID] in account_ids: + errors[CONFIG_ACCOUNT_ID] = "duplicate_account" + return errors + + if CONFIG_MAIN_API_KEY not in data: + errors[CONFIG_MAIN_API_KEY] = "api_key_not_set" + return errors + client = OctopusEnergyApiClient(data[CONFIG_MAIN_API_KEY]) try: diff --git a/custom_components/octopus_energy/config/target_rates.py b/custom_components/octopus_energy/config/target_rates.py index 21ce27193..c53279e29 100644 --- a/custom_components/octopus_energy/config/target_rates.py +++ b/custom_components/octopus_energy/config/target_rates.py @@ -9,6 +9,8 @@ CONFIG_ACCOUNT_ID, CONFIG_TARGET_END_TIME, CONFIG_TARGET_HOURS, + CONFIG_TARGET_MAX_RATE, + CONFIG_TARGET_MIN_RATE, CONFIG_TARGET_MPAN, CONFIG_TARGET_NAME, CONFIG_TARGET_OFFSET, @@ -20,15 +22,20 @@ CONFIG_TARGET_OLD_TYPE, CONFIG_TARGET_START_TIME, CONFIG_TARGET_TYPE, + CONFIG_TARGET_TYPE_CONTINUOUS, + CONFIG_TARGET_WEIGHTING, DOMAIN, REGEX_ENTITY_NAME, REGEX_HOURS, REGEX_OFFSET_PARTS, - REGEX_TIME + REGEX_PRICE, + REGEX_TIME, + REGEX_WEIGHTING ) -from ..utils import get_active_tariff_code +from . import get_meter_tariffs from ..utils.tariff_check import is_agile_tariff +from ..target_rates import create_weighting async def async_migrate_target_config(version: int, data: {}, get_entries): new_data = {**data} @@ -78,17 +85,25 @@ def merge_target_rate_config(data: dict, options: dict, updated_config: dict = N if updated_config is not None: config.update(updated_config) - return config + if CONFIG_TARGET_START_TIME not in updated_config and CONFIG_TARGET_START_TIME in config: + config[CONFIG_TARGET_START_TIME] = None + + if CONFIG_TARGET_END_TIME not in updated_config and CONFIG_TARGET_END_TIME in config: + config[CONFIG_TARGET_END_TIME] = None + + if CONFIG_TARGET_OFFSET not in updated_config and CONFIG_TARGET_OFFSET in config: + config[CONFIG_TARGET_OFFSET] = None + + if CONFIG_TARGET_MIN_RATE not in updated_config and CONFIG_TARGET_MIN_RATE in config: + config[CONFIG_TARGET_MIN_RATE] = None + + if CONFIG_TARGET_MAX_RATE not in updated_config and CONFIG_TARGET_MAX_RATE in config: + config[CONFIG_TARGET_MAX_RATE] = None -def get_meter_tariffs(account_info, now): - meters = {} - if account_info is not None and len(account_info["electricity_meter_points"]) > 0: - for point in account_info["electricity_meter_points"]: - active_tariff_code = get_active_tariff_code(now, point["agreements"]) - if active_tariff_code is not None: - meters[point["mpan"]] = active_tariff_code + if CONFIG_TARGET_WEIGHTING not in updated_config and CONFIG_TARGET_WEIGHTING in config: + config[CONFIG_TARGET_WEIGHTING] = None - return meters + return config def is_time_frame_long_enough(hours, start_time, end_time): start_time = parse_datetime(f"2023-08-01T{start_time}:00Z") @@ -147,6 +162,37 @@ def validate_target_rate_config(data, account_info, now): if matches is None: errors[CONFIG_TARGET_OFFSET] = "invalid_offset" + if CONFIG_TARGET_MIN_RATE in data and data[CONFIG_TARGET_MIN_RATE] is not None: + if isinstance(data[CONFIG_TARGET_MIN_RATE], float) == False: + matches = re.search(REGEX_PRICE, data[CONFIG_TARGET_MIN_RATE]) + if matches is None: + errors[CONFIG_TARGET_MIN_RATE] = "invalid_price" + else: + data[CONFIG_TARGET_MIN_RATE] = float(data[CONFIG_TARGET_MIN_RATE]) + + if CONFIG_TARGET_MAX_RATE in data and data[CONFIG_TARGET_MAX_RATE] is not None: + if isinstance(data[CONFIG_TARGET_MAX_RATE], float) == False: + matches = re.search(REGEX_PRICE, data[CONFIG_TARGET_MAX_RATE]) + if matches is None: + errors[CONFIG_TARGET_MAX_RATE] = "invalid_price" + else: + data[CONFIG_TARGET_MAX_RATE] = float(data[CONFIG_TARGET_MAX_RATE]) + + if CONFIG_TARGET_WEIGHTING in data and data[CONFIG_TARGET_WEIGHTING] is not None: + matches = re.search(REGEX_WEIGHTING, data[CONFIG_TARGET_WEIGHTING]) + if matches is None: + errors[CONFIG_TARGET_WEIGHTING] = "invalid_weighting" + + if CONFIG_TARGET_WEIGHTING not in errors: + number_of_slots = int(data[CONFIG_TARGET_HOURS] * 2) + weighting = create_weighting(data[CONFIG_TARGET_WEIGHTING], number_of_slots) + + if (len(weighting) != number_of_slots): + errors[CONFIG_TARGET_WEIGHTING] = "invalid_weighting_slots" + + if data[CONFIG_TARGET_TYPE] != CONFIG_TARGET_TYPE_CONTINUOUS: + errors[CONFIG_TARGET_WEIGHTING] = "weighting_not_supported" + start_time = data[CONFIG_TARGET_START_TIME] if CONFIG_TARGET_START_TIME in data else "00:00" end_time = data[CONFIG_TARGET_END_TIME] if CONFIG_TARGET_END_TIME in data else "00:00" diff --git a/custom_components/octopus_energy/config_flow.py b/custom_components/octopus_energy/config_flow.py index a368a24f3..ef327bbc9 100644 --- a/custom_components/octopus_energy/config_flow.py +++ b/custom_components/octopus_energy/config_flow.py @@ -11,14 +11,16 @@ ) from .coordinators.account import AccountCoordinatorResult -from .config.cost_tracker import validate_cost_tracker_config +from .config.cost_tracker import merge_cost_tracker_config, validate_cost_tracker_config from .config.target_rates import merge_target_rate_config, validate_target_rate_config from .config.main import async_validate_main_config, merge_main_config from .const import ( CONFIG_COST_ENTITY_ACCUMULATIVE_VALUE, + CONFIG_COST_MONTH_DAY_RESET, CONFIG_COST_TARGET_ENTITY_ID, CONFIG_COST_MPAN, CONFIG_COST_NAME, + CONFIG_COST_WEEKDAY_RESET, CONFIG_DEFAULT_LIVE_ELECTRICITY_CONSUMPTION_REFRESH_IN_MINUTES, CONFIG_DEFAULT_LIVE_GAS_CONSUMPTION_REFRESH_IN_MINUTES, CONFIG_DEFAULT_PREVIOUS_CONSUMPTION_OFFSET_IN_DAYS, @@ -31,6 +33,11 @@ CONFIG_MAIN_LIVE_GAS_CONSUMPTION_REFRESH_IN_MINUTES, CONFIG_MAIN_PREVIOUS_ELECTRICITY_CONSUMPTION_DAYS_OFFSET, CONFIG_MAIN_PREVIOUS_GAS_CONSUMPTION_DAYS_OFFSET, + CONFIG_TARGET_MAX_RATE, + CONFIG_TARGET_MIN_RATE, + CONFIG_TARGET_TYPE_CONTINUOUS, + CONFIG_TARGET_TYPE_INTERMITTENT, + CONFIG_TARGET_WEIGHTING, CONFIG_VERSION, DATA_ACCOUNT, DOMAIN, @@ -53,7 +60,6 @@ CONFIG_TARGET_INVERT_TARGET_RATES, DATA_SCHEMA_ACCOUNT, - DATA_CLIENT, ) from .utils import get_active_tariff_code @@ -77,12 +83,23 @@ def get_target_rate_meters(account_info, now): return meters +def get_weekday_options(): + return [ + selector.SelectOptionDict(value="0", label="Monday"), + selector.SelectOptionDict(value="1", label="Tuesday"), + selector.SelectOptionDict(value="2", label="Wednesday"), + selector.SelectOptionDict(value="3", label="Thursday"), + selector.SelectOptionDict(value="4", label="Friday"), + selector.SelectOptionDict(value="5", label="Saturday"), + selector.SelectOptionDict(value="6", label="Sunday"), + ] + def get_account_ids(hass): - account_ids = {} + account_ids: list[str] = [] for entry in hass.config_entries.async_entries(DOMAIN): if CONFIG_KIND in entry.data and entry.data[CONFIG_KIND] == CONFIG_KIND_ACCOUNT: account_id = entry.data[CONFIG_ACCOUNT_ID] - account_ids[account_id] = account_id + account_ids.append(account_id) return account_ids @@ -93,9 +110,10 @@ class OctopusEnergyConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_account(self, user_input): """Setup the initial account based on the provided user input""" - errors = await async_validate_main_config(user_input) + account_ids = get_account_ids(self.hass) + errors = await async_validate_main_config(user_input, account_ids) if user_input is not None else {} - if len(errors) < 1: + if len(errors) < 1 and user_input is not None: user_input[CONFIG_KIND] = CONFIG_KIND_ACCOUNT return self.async_create_entry( title=user_input[CONFIG_ACCOUNT_ID], @@ -103,14 +121,34 @@ async def async_step_account(self, user_input): ) return self.async_show_form( - step_id="account", data_schema=DATA_SCHEMA_ACCOUNT, errors=errors + step_id="account", + data_schema=self.add_suggested_values_to_schema( + DATA_SCHEMA_ACCOUNT, + user_input if user_input is not None else {} + ), + errors=errors ) - - async def __async_setup_target_rate_schema__(self): + + def __capture_account_id__(self, step_id: str): account_ids = get_account_ids(self.hass) - account_id = list(account_ids.keys())[0] + account_id_options = [] + for account_id in account_ids: + account_id_options.append(selector.SelectOptionDict(value=account_id, label=account_id.upper())) - account_info: AccountCoordinatorResult = self.hass.data[DOMAIN][account_id][DATA_ACCOUNT] + return self.async_show_form( + step_id=step_id, + data_schema=vol.Schema({ + vol.Required(CONFIG_ACCOUNT_ID): selector.SelectSelector( + selector.SelectSelectorConfig( + options=account_id_options, + mode=selector.SelectSelectorMode.DROPDOWN, + ) + ), + }) + ) + + async def __async_setup_target_rate_schema__(self, account_id: str): + account_info: AccountCoordinatorResult = self.hass.data[DOMAIN][account_id][DATA_ACCOUNT] if account_id is not None and account_id in self.hass.data[DOMAIN] else None if (account_info is None): return self.async_abort(reason="account_not_found") @@ -120,11 +158,11 @@ async def __async_setup_target_rate_schema__(self): return vol.Schema({ vol.Required(CONFIG_TARGET_NAME): str, vol.Required(CONFIG_TARGET_HOURS): str, - vol.Required(CONFIG_TARGET_TYPE, default="Continuous"): selector.SelectSelector( + vol.Required(CONFIG_TARGET_TYPE, default=CONFIG_TARGET_TYPE_CONTINUOUS): selector.SelectSelector( selector.SelectSelectorConfig( options=[ - selector.SelectOptionDict(value="Continuous", label="Continuous"), - selector.SelectOptionDict(value="Intermittent", label="Intermittent"), + selector.SelectOptionDict(value=CONFIG_TARGET_TYPE_CONTINUOUS, label="Continuous"), + selector.SelectOptionDict(value=CONFIG_TARGET_TYPE_INTERMITTENT, label="Intermittent"), ], mode=selector.SelectSelectorMode.DROPDOWN, ) @@ -141,13 +179,14 @@ async def __async_setup_target_rate_schema__(self): vol.Optional(CONFIG_TARGET_ROLLING_TARGET, default=False): bool, vol.Optional(CONFIG_TARGET_LAST_RATES, default=False): bool, vol.Optional(CONFIG_TARGET_INVERT_TARGET_RATES, default=False): bool, + vol.Optional(CONFIG_TARGET_MIN_RATE): str, + vol.Optional(CONFIG_TARGET_MAX_RATE): str, + vol.Optional(CONFIG_TARGET_WEIGHTING): str, }) - async def __async_setup_cost_tracker_schema__(self): - account_ids = get_account_ids(self.hass) - account_id = list(account_ids.keys())[0] + async def __async_setup_cost_tracker_schema__(self, account_id: str): - account_info: AccountCoordinatorResult = self.hass.data[DOMAIN][account_id][DATA_ACCOUNT] + account_info: AccountCoordinatorResult = self.hass.data[DOMAIN][account_id][DATA_ACCOUNT] if account_id is not None and account_id in self.hass.data[DOMAIN] else None if (account_info is None): return self.async_abort(reason="account_not_found") @@ -166,60 +205,100 @@ async def __async_setup_cost_tracker_schema__(self): selector.EntitySelectorConfig(domain="sensor", device_class=[SensorDeviceClass.ENERGY]), ), vol.Optional(CONFIG_COST_ENTITY_ACCUMULATIVE_VALUE, default=False): bool, + vol.Required(CONFIG_COST_WEEKDAY_RESET, default="0"): selector.SelectSelector( + selector.SelectSelectorConfig( + options=get_weekday_options(), + mode=selector.SelectSelectorMode.DROPDOWN, + ) + ), + vol.Required(CONFIG_COST_MONTH_DAY_RESET, default=1): cv.positive_int, }) + + async def async_step_target_rate_account(self, user_input): + if user_input is None or CONFIG_ACCOUNT_ID not in user_input: + return self.__capture_account_id__("target_rate_account") + + self._account_id = user_input[CONFIG_ACCOUNT_ID] + + return await self.async_step_target_rate(None) async def async_step_target_rate(self, user_input): """Setup a target based on the provided user input""" - account_ids = get_account_ids(self.hass) - account_id = list(account_ids.keys())[0] + account_id = self._account_id account_info: AccountCoordinatorResult = self.hass.data[DOMAIN][account_id][DATA_ACCOUNT] if (account_info is None): return self.async_abort(reason="account_not_found") now = utcnow() - errors = validate_target_rate_config(user_input, account_info.account, now) if user_input is not None else {} + config = dict(user_input) if user_input is not None else None + errors = validate_target_rate_config(config, account_info.account, now) if config is not None else {} if len(errors) < 1 and user_input is not None: - user_input[CONFIG_KIND] = CONFIG_KIND_TARGET_RATE - user_input[CONFIG_ACCOUNT_ID] = account_id + config[CONFIG_KIND] = CONFIG_KIND_TARGET_RATE + config[CONFIG_ACCOUNT_ID] = self._account_id # Setup our targets sensor return self.async_create_entry( - title=f"{user_input[CONFIG_TARGET_NAME]} (target)", - data=user_input + title=f"{config[CONFIG_TARGET_NAME]} (target)", + data=config ) # Reshow our form with raised logins - data_Schema = await self.__async_setup_target_rate_schema__() + data_schema = await self.__async_setup_target_rate_schema__(self._account_id) return self.async_show_form( - step_id="target_rate", data_schema=data_Schema, errors=errors + step_id="target_rate", + data_schema=self.add_suggested_values_to_schema( + data_schema, + user_input if user_input is not None else {} + ), + errors=errors ) + + async def async_step_cost_tracker_account(self, user_input): + if user_input is None or CONFIG_ACCOUNT_ID not in user_input: + return self.__capture_account_id__("cost_tracker_account") + + self._account_id = user_input[CONFIG_ACCOUNT_ID] + + return await self.async_step_cost_tracker(None) async def async_step_cost_tracker(self, user_input): """Setup a target based on the provided user input""" - errors = validate_cost_tracker_config(user_input) if user_input is not None else {} - account_ids = get_account_ids(self.hass) + account_id = self._account_id + + account_info: AccountCoordinatorResult = self.hass.data[DOMAIN][account_id][DATA_ACCOUNT] + if (account_info is None): + return self.async_abort(reason="account_not_found") + + now = utcnow() + errors = validate_cost_tracker_config(user_input, account_info.account, now) if user_input is not None else {} if len(errors) < 1 and user_input is not None: user_input[CONFIG_KIND] = CONFIG_KIND_COST_TRACKER - user_input[CONFIG_ACCOUNT_ID] = list(account_ids.keys())[0] + user_input[CONFIG_ACCOUNT_ID] = account_id return self.async_create_entry( title=f"{user_input[CONFIG_COST_NAME]} (cost tracker)", data=user_input ) # Reshow our form with raised logins - data_Schema = await self.__async_setup_cost_tracker_schema__() + data_schema = await self.__async_setup_cost_tracker_schema__(self._account_id) return self.async_show_form( - step_id="cost_tracker", data_schema=data_Schema, errors=errors + step_id="cost_tracker", + data_schema=self.add_suggested_values_to_schema( + data_schema, + user_input if user_input is not None else {} + ), + errors=errors ) async def async_step_choice(self, user_input): """Setup choice menu""" return self.async_show_menu( step_id="choice", menu_options={ - "target_rate": "Target Rate", - "cost_tracker": "Cost Tracker" + "account": "New Account", + "target_rate_account": "Target Rate", + "cost_tracker_account": "Cost Tracker" } ) @@ -249,7 +328,8 @@ async def async_step_user(self, user_input): return await self.async_step_choice(user_input) return self.async_show_form( - step_id="account", data_schema=DATA_SCHEMA_ACCOUNT + step_id="account", + data_schema=DATA_SCHEMA_ACCOUNT ) @staticmethod @@ -264,10 +344,9 @@ def __init__(self, entry) -> None: self._entry = entry async def __async_setup_target_rate_schema__(self, config, errors): - account_ids = get_account_ids(self.hass) - account_id = list(account_ids.keys())[0] + account_id = config[CONFIG_ACCOUNT_ID] if CONFIG_ACCOUNT_ID in config else None - account_info: AccountCoordinatorResult = self.hass.data[DOMAIN][account_id][DATA_ACCOUNT] + account_info: AccountCoordinatorResult = self.hass.data[DOMAIN][account_id][DATA_ACCOUNT] if account_id is not None and account_id in self.hass.data[DOMAIN] else None if account_info is None: errors[CONFIG_TARGET_MPAN] = "account_not_found" @@ -308,11 +387,11 @@ async def __async_setup_target_rate_schema__(self, config, errors): vol.Schema({ vol.Required(CONFIG_TARGET_NAME): str, vol.Required(CONFIG_TARGET_HOURS): str, - vol.Required(CONFIG_TARGET_TYPE, default="Continuous"): selector.SelectSelector( + vol.Required(CONFIG_TARGET_TYPE, default=CONFIG_TARGET_TYPE_CONTINUOUS): selector.SelectSelector( selector.SelectSelectorConfig( options=[ - selector.SelectOptionDict(value="Continuous", label="Continuous"), - selector.SelectOptionDict(value="Intermittent", label="Intermittent"), + selector.SelectOptionDict(value=CONFIG_TARGET_TYPE_CONTINUOUS, label="Continuous"), + selector.SelectOptionDict(value=CONFIG_TARGET_TYPE_INTERMITTENT, label="Intermittent"), ], mode=selector.SelectSelectorMode.DROPDOWN, ) @@ -329,6 +408,9 @@ async def __async_setup_target_rate_schema__(self, config, errors): vol.Optional(CONFIG_TARGET_ROLLING_TARGET): bool, vol.Optional(CONFIG_TARGET_LAST_RATES): bool, vol.Optional(CONFIG_TARGET_INVERT_TARGET_RATES): bool, + vol.Optional(CONFIG_TARGET_MIN_RATE): str, + vol.Optional(CONFIG_TARGET_MAX_RATE): str, + vol.Optional(CONFIG_TARGET_WEIGHTING): str, }), { CONFIG_TARGET_NAME: config[CONFIG_TARGET_NAME], @@ -338,6 +420,9 @@ async def __async_setup_target_rate_schema__(self, config, errors): CONFIG_TARGET_ROLLING_TARGET: is_rolling_target, CONFIG_TARGET_LAST_RATES: find_last_rates, CONFIG_TARGET_INVERT_TARGET_RATES: invert_target_rates, + CONFIG_TARGET_MIN_RATE: config[CONFIG_TARGET_MIN_RATE] if CONFIG_TARGET_MIN_RATE in config else None, + CONFIG_TARGET_MAX_RATE: config[CONFIG_TARGET_MAX_RATE] if CONFIG_TARGET_MAX_RATE in config else None, + CONFIG_TARGET_WEIGHTING: config[CONFIG_TARGET_WEIGHTING] if CONFIG_TARGET_WEIGHTING in config else None, } ), errors=errors @@ -396,6 +481,51 @@ async def __async_setup_main_schema__(self, config, errors): ), errors=errors ) + + async def __async_setup_cost_tracker_schema__(self, config, errors): + account_id = config[CONFIG_ACCOUNT_ID] + + account_info: AccountCoordinatorResult = self.hass.data[DOMAIN][account_id][DATA_ACCOUNT] + if account_info is None: + errors[CONFIG_TARGET_MPAN] = "account_not_found" + + now = utcnow() + meters = get_target_rate_meters(account_info.account, now) + + return self.async_show_form( + step_id="cost_tracker", + data_schema=self.add_suggested_values_to_schema( + vol.Schema({ + vol.Required(CONFIG_COST_NAME): str, + vol.Required(CONFIG_TARGET_MPAN): selector.SelectSelector( + selector.SelectSelectorConfig( + options=meters, + mode=selector.SelectSelectorMode.DROPDOWN, + ) + ), + vol.Required(CONFIG_COST_TARGET_ENTITY_ID): selector.EntitySelector( + selector.EntitySelectorConfig(domain="sensor", device_class=[SensorDeviceClass.ENERGY]), + ), + vol.Optional(CONFIG_COST_ENTITY_ACCUMULATIVE_VALUE): bool, + vol.Required(CONFIG_COST_WEEKDAY_RESET): selector.SelectSelector( + selector.SelectSelectorConfig( + options=get_weekday_options(), + mode=selector.SelectSelectorMode.DROPDOWN, + ) + ), + vol.Required(CONFIG_COST_MONTH_DAY_RESET): cv.positive_int, + }), + { + CONFIG_COST_NAME: config[CONFIG_COST_NAME], + CONFIG_TARGET_MPAN: config[CONFIG_TARGET_MPAN], + CONFIG_COST_TARGET_ENTITY_ID: config[CONFIG_COST_TARGET_ENTITY_ID], + CONFIG_COST_ENTITY_ACCUMULATIVE_VALUE: config[CONFIG_COST_ENTITY_ACCUMULATIVE_VALUE], + CONFIG_COST_WEEKDAY_RESET: f"{config[CONFIG_COST_WEEKDAY_RESET]}" if CONFIG_COST_WEEKDAY_RESET in config else "0", + CONFIG_COST_MONTH_DAY_RESET: config[CONFIG_COST_MONTH_DAY_RESET] if CONFIG_COST_MONTH_DAY_RESET in config else 1, + } + ), + errors=errors + ) async def async_step_init(self, user_input): """Manage the options for the custom component.""" @@ -408,7 +538,9 @@ async def async_step_init(self, user_input): config = merge_target_rate_config(self._entry.data, self._entry.options, user_input) return await self.__async_setup_target_rate_schema__(config, {}) - # if (kind == CONFIG_KIND_COST_SENSOR): + if (kind == CONFIG_KIND_COST_TRACKER): + config = merge_cost_tracker_config(self._entry.data, self._entry.options, user_input) + return await self.__async_setup_cost_tracker_schema__(config, {}) return self.async_abort(reason="not_supported") @@ -426,18 +558,34 @@ async def async_step_user(self, user_input): async def async_step_target_rate(self, user_input): """Manage the options for the custom component.""" - account_ids = get_account_ids(self.hass) - account_id = list(account_ids.keys())[0] - config = merge_target_rate_config(self._entry.data, self._entry.options, user_input) + account_id = config[CONFIG_ACCOUNT_ID] if CONFIG_ACCOUNT_ID in config else None - client = self.hass.data[DOMAIN][account_id][DATA_CLIENT] - account_info = await client.async_get_account(account_id) + account_info: AccountCoordinatorResult = self.hass.data[DOMAIN][account_id][DATA_ACCOUNT] if account_id is not None and account_id in self.hass.data[DOMAIN] else None + if account_info is None: + errors[CONFIG_TARGET_MPAN] = "account_not_found" now = utcnow() - errors = validate_target_rate_config(user_input, account_info, now) + errors = validate_target_rate_config(config, account_info.account, now) if (len(errors) > 0): return await self.__async_setup_target_rate_schema__(config, errors) + return self.async_create_entry(title="", data=config) + + async def async_step_cost_tracker(self, user_input): + """Manage the options for the custom component.""" + config = merge_cost_tracker_config(self._entry.data, self._entry.options, user_input) + account_id = config[CONFIG_ACCOUNT_ID] if CONFIG_ACCOUNT_ID in config else None + + account_info: AccountCoordinatorResult = self.hass.data[DOMAIN][account_id][DATA_ACCOUNT] + if (account_info is None): + return self.async_abort(reason="account_not_found") + + now = utcnow() + errors = validate_cost_tracker_config(config, account_info.account, now) + + if (len(errors) > 0): + return await self.__async_setup_cost_tracker_schema__(config, errors) + return self.async_create_entry(title="", data=config) \ No newline at end of file diff --git a/custom_components/octopus_energy/const.py b/custom_components/octopus_energy/const.py index 470e17355..8ac46d999 100644 --- a/custom_components/octopus_energy/const.py +++ b/custom_components/octopus_energy/const.py @@ -2,7 +2,7 @@ import homeassistant.helpers.config_validation as cv DOMAIN = "octopus_energy" -INTEGRATION_VERSION = "10.0.4" +INTEGRATION_VERSION = "11.0.0" REFRESH_RATE_IN_MINUTES_ACCOUNT = 60 REFRESH_RATE_IN_MINUTES_INTELLIGENT = 5 @@ -11,6 +11,8 @@ REFRESH_RATE_IN_MINUTES_STANDING_CHARGE = 60 REFRESH_RATE_IN_MINUTES_OCTOPLUS_SAVING_SESSIONS = 15 REFRESH_RATE_IN_MINUTES_OCTOPLUS_WHEEL_OF_FORTUNE = 60 +REFRESH_RATE_IN_MINUTES_OCTOPLUS_POINTS = 60 +REFRESH_RATE_IN_MINUTES_GREENNESS_FORECAST = 180 CONFIG_VERSION = 3 @@ -47,6 +49,8 @@ CONFIG_TARGET_NAME = "name" CONFIG_TARGET_HOURS = "hours" CONFIG_TARGET_TYPE = "type" +CONFIG_TARGET_TYPE_CONTINUOUS = "Continuous" +CONFIG_TARGET_TYPE_INTERMITTENT = "Intermittent" CONFIG_TARGET_START_TIME = "start_time" CONFIG_TARGET_END_TIME = "end_time" CONFIG_TARGET_MPAN = "mpan" @@ -54,11 +58,16 @@ CONFIG_TARGET_ROLLING_TARGET = "rolling_target" CONFIG_TARGET_LAST_RATES = "last_rates" CONFIG_TARGET_INVERT_TARGET_RATES = "target_invert_target_rates" +CONFIG_TARGET_MIN_RATE = "minimum_rate" +CONFIG_TARGET_MAX_RATE = "maximum_rate" +CONFIG_TARGET_WEIGHTING = "weighting" CONFIG_COST_NAME = "name" CONFIG_COST_MPAN = "mpan" CONFIG_COST_TARGET_ENTITY_ID = "target_entity_id" CONFIG_COST_ENTITY_ACCUMULATIVE_VALUE = "entity_accumulative_value" +CONFIG_COST_WEEKDAY_RESET = "weekday_reset" +CONFIG_COST_MONTH_DAY_RESET = "month_day_reset" DATA_CONFIG = "CONFIG" DATA_ELECTRICITY_RATES_COORDINATOR_KEY = "ELECTRICITY_RATES_COORDINATOR_{}_{}" @@ -83,10 +92,17 @@ DATA_GAS_STANDING_CHARGE_KEY = "GAS_STANDING_CHARGES_{}_{}" DATA_WHEEL_OF_FORTUNE_SPINS = "WHEEL_OF_FORTUNE_SPINS" DATA_CURRENT_CONSUMPTION_KEY = "CURRENT_CONSUMPTION_{}" +DATA_GREENNESS_FORECAST_COORDINATOR = "GREENNESS_FORECAST_COORDINATOR" +DATA_GREENNESS_FORECAST = "GREENNESS_FORECAST" DATA_SAVING_SESSIONS_FORCE_UPDATE = "SAVING_SESSIONS_FORCE_UPDATE" STORAGE_COMPLETED_DISPATCHES_NAME = "octopus_energy.{}-completed-intelligent-dispatches.json" +STORAGE_ELECTRICITY_TARIFF_OVERRIDE_NAME = "octopus_energy.{}-{}-tariff-override.json" +STORAGE_TARIFF_CACHE_NAME = "octopus_energy.tariff-{}.json" + +INTELLIGENT_SOURCE_SMART_CHARGE = "smart-charge" +INTELLIGENT_SOURCE_BUMP_CHARGE = "bump-charge" REGEX_HOURS = "^[0-9]+(\\.[0-9]+)*$" REGEX_TIME = "^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$" @@ -96,10 +112,17 @@ REGEX_TARIFF_PARTS = "^((?P[A-Z])-(?P[0-9A-Z]+)-)?(?P[A-Z0-9-]+)-(?P[A-Z])$" REGEX_OFFSET_PARTS = "^(-)?([0-1]?[0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])$" REGEX_DATE = "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" +REGEX_PRICE = "^(-)?[0-9]+(\\.[0-9]+)*$" + +REGEX_WEIGHTING_NUMBERS = "([0-9](,[0-9]+)*)" +REGEX_WEIGHTING_START = "(\\*(,[0-9]+)+)" +REGEX_WEIGHTING_MIDDLE = "([0-9](,[0-9]+)*(,\\*)(,[0-9]+)+)" +REGEX_WEIGHTING_END = "([0-9](,[0-9]+)*(,\\*))" +REGEX_WEIGHTING = f"^({REGEX_WEIGHTING_NUMBERS}|{REGEX_WEIGHTING_START}|{REGEX_WEIGHTING_MIDDLE}|{REGEX_WEIGHTING_END})$" DATA_SCHEMA_ACCOUNT = vol.Schema({ - vol.Required(CONFIG_MAIN_API_KEY): str, vol.Required(CONFIG_ACCOUNT_ID): str, + vol.Required(CONFIG_MAIN_API_KEY): str, vol.Required(CONFIG_MAIN_SUPPORTS_LIVE_CONSUMPTION): bool, vol.Required(CONFIG_MAIN_LIVE_ELECTRICITY_CONSUMPTION_REFRESH_IN_MINUTES, default=CONFIG_DEFAULT_LIVE_ELECTRICITY_CONSUMPTION_REFRESH_IN_MINUTES): cv.positive_int, vol.Required(CONFIG_MAIN_LIVE_GAS_CONSUMPTION_REFRESH_IN_MINUTES, default=CONFIG_DEFAULT_LIVE_ELECTRICITY_CONSUMPTION_REFRESH_IN_MINUTES): cv.positive_int, @@ -128,4 +151,4 @@ # During BST, two records are returned before the rest of the data is available MINIMUM_CONSUMPTION_DATA_LENGTH = 3 -COORDINATOR_REFRESH_IN_SECONDS = 60 +COORDINATOR_REFRESH_IN_SECONDS = 60 \ No newline at end of file diff --git a/custom_components/octopus_energy/coordinators/__init__.py b/custom_components/octopus_energy/coordinators/__init__.py index 0bba4bf76..685de8a2f 100644 --- a/custom_components/octopus_energy/coordinators/__init__.py +++ b/custom_components/octopus_energy/coordinators/__init__.py @@ -136,7 +136,7 @@ def get_electricity_meter_tariff_code(current: datetime, account_info, target_mp # have to enumerate the different meters being used for each tariff as well. for meter in point["meters"]: if active_tariff_code is not None and point["mpan"] == target_mpan and meter["serial_number"] == target_serial_number: - return active_tariff_code + return active_tariff_code return None diff --git a/custom_components/octopus_energy/coordinators/account.py b/custom_components/octopus_energy/coordinators/account.py index 0e21211bb..f9b48f8c4 100644 --- a/custom_components/octopus_energy/coordinators/account.py +++ b/custom_components/octopus_energy/coordinators/account.py @@ -108,7 +108,7 @@ async def async_update_account_data(): hass.data[DOMAIN][account_id][DATA_ACCOUNT_COORDINATOR] = DataUpdateCoordinator( hass, _LOGGER, - name="update_account", + name=f"update_account-{account_id}", update_method=async_update_account_data, # Because of how we're using the data, we'll update every minute, but we will only actually retrieve # data every 30 minutes diff --git a/custom_components/octopus_energy/coordinators/current_consumption.py b/custom_components/octopus_energy/coordinators/current_consumption.py index 57c02323c..212c488ca 100644 --- a/custom_components/octopus_energy/coordinators/current_consumption.py +++ b/custom_components/octopus_energy/coordinators/current_consumption.py @@ -1,6 +1,5 @@ from datetime import (datetime, timedelta) import logging -from custom_components.octopus_energy.coordinators import BaseCoordinatorResult from homeassistant.util.dt import (now) from homeassistant.helpers.update_coordinator import ( @@ -14,6 +13,7 @@ ) from ..api_client import (ApiException, OctopusEnergyApiClient) +from . import BaseCoordinatorResult _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/octopus_energy/coordinators/electricity_rates.py b/custom_components/octopus_energy/coordinators/electricity_rates.py index e35d11648..59f7c0af8 100644 --- a/custom_components/octopus_energy/coordinators/electricity_rates.py +++ b/custom_components/octopus_energy/coordinators/electricity_rates.py @@ -25,7 +25,7 @@ from ..coordinators.intelligent_dispatches import IntelligentDispatchesCoordinatorResult from ..utils import private_rates_to_public_rates from . import BaseCoordinatorResult, get_electricity_meter_tariff_code, raise_rate_events -from ..intelligent import adjust_intelligent_rates +from ..intelligent import adjust_intelligent_rates, is_intelligent_tariff _LOGGER = logging.getLogger(__name__) @@ -52,16 +52,21 @@ async def async_refresh_electricity_rates_data( dispatches_result: IntelligentDispatchesCoordinatorResult, planned_dispatches_supported: bool, fire_event: Callable[[str, "dict[str, Any]"], None], + tariff_override = None ) -> ElectricityRatesCoordinatorResult: if (account_info is not None): period_from = as_utc((current - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)) period_to = as_utc((current + timedelta(days=2)).replace(hour=0, minute=0, second=0, microsecond=0)) - tariff_code = get_electricity_meter_tariff_code(current, account_info, target_mpan, target_serial_number) + tariff_code = get_electricity_meter_tariff_code(current, account_info, target_mpan, target_serial_number) if tariff_override is None else tariff_override if tariff_code is None: return None + + # We'll calculate the wrong value if we don't have our intelligent dispatches + if is_intelligent_tariff(tariff_code) and (dispatches_result is None or dispatches_result.dispatches is None): + return existing_rates_result - new_rates: list = None + new_rates = None if (existing_rates_result is None or current >= existing_rates_result.next_refresh): try: new_rates = await client.async_get_electricity_rates(tariff_code, is_smart_meter, period_from, period_to) @@ -154,7 +159,14 @@ async def async_refresh_electricity_rates_data( ) return existing_rates_result -async def async_setup_electricity_rates_coordinator(hass, account_id: str, target_mpan: str, target_serial_number: str, is_smart_meter: bool, is_export_meter: bool, planned_dispatches_supported: bool): +async def async_setup_electricity_rates_coordinator(hass, + account_id: str, + target_mpan: str, + target_serial_number: str, + is_smart_meter: bool, + is_export_meter: bool, + planned_dispatches_supported: bool, + tariff_override = None): key = DATA_ELECTRICITY_RATES_KEY.format(target_mpan, target_serial_number) # Reset data rates as we might have new information @@ -180,7 +192,8 @@ async def async_update_electricity_rates_data(): rates, dispatches, planned_dispatches_supported, - hass.bus.async_fire + hass.bus.async_fire, + tariff_override ) return hass.data[DOMAIN][account_id][key] diff --git a/custom_components/octopus_energy/coordinators/gas_rates.py b/custom_components/octopus_energy/coordinators/gas_rates.py index 4f8e93006..49efac962 100644 --- a/custom_components/octopus_energy/coordinators/gas_rates.py +++ b/custom_components/octopus_energy/coordinators/gas_rates.py @@ -48,7 +48,7 @@ async def async_refresh_gas_rates_data( if tariff_code is None: return None - new_rates: list = None + new_rates = None if (existing_rates_result is None or current >= existing_rates_result.next_refresh): try: diff --git a/custom_components/octopus_energy/coordinators/intelligent_dispatches.py b/custom_components/octopus_energy/coordinators/intelligent_dispatches.py index 1ce78c35a..7d717a3da 100644 --- a/custom_components/octopus_energy/coordinators/intelligent_dispatches.py +++ b/custom_components/octopus_energy/coordinators/intelligent_dispatches.py @@ -10,6 +10,7 @@ from ..const import ( COORDINATOR_REFRESH_IN_SECONDS, + DATA_INTELLIGENT_DEVICE, DOMAIN, DATA_CLIENT, @@ -25,6 +26,7 @@ from ..api_client import ApiException, OctopusEnergyApiClient from ..api_client.intelligent_dispatches import IntelligentDispatches from . import BaseCoordinatorResult +from ..api_client.intelligent_device import IntelligentDevice from ..intelligent import async_mock_intelligent_data, clean_previous_dispatches, dictionary_list_to_dispatches, dispatches_to_dictionary_list, has_intelligent_tariff, mock_intelligent_dispatches @@ -58,6 +60,7 @@ async def async_refresh_intelligent_dispatches( current: datetime, client: OctopusEnergyApiClient, account_info, + intelligent_device: IntelligentDevice, existing_intelligent_dispatches_result: IntelligentDispatchesCoordinatorResult, is_data_mocked: bool, async_merge_dispatch_data: Callable[[str, list], Awaitable[list]] @@ -66,7 +69,7 @@ async def async_refresh_intelligent_dispatches( account_id = account_info["id"] if (existing_intelligent_dispatches_result is None or current >= existing_intelligent_dispatches_result.next_refresh): dispatches = None - if has_intelligent_tariff(current, account_info): + if has_intelligent_tariff(current, account_info) and intelligent_device is not None: try: dispatches = await client.async_get_intelligent_dispatches(account_id) _LOGGER.debug(f'Intelligent dispatches retrieved for account {account_id}') @@ -120,6 +123,7 @@ async def async_update_intelligent_dispatches_data(): current, client, account_info, + hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DEVICE] if DATA_INTELLIGENT_DEVICE in hass.data[DOMAIN][account_id] else None, hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DISPATCHES] if DATA_INTELLIGENT_DISPATCHES in hass.data[DOMAIN][account_id] else None, await async_mock_intelligent_data(hass, account_id), lambda account_id, completed_dispatches: async_merge_dispatch_data(hass, account_id, completed_dispatches) @@ -130,7 +134,7 @@ async def async_update_intelligent_dispatches_data(): hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DISPATCHES_COORDINATOR] = DataUpdateCoordinator( hass, _LOGGER, - name="intelligent_dispatches", + name=f"intelligent_dispatches-{account_id}", update_method=async_update_intelligent_dispatches_data, # Because of how we're using the data, we'll update every minute, but we will only actually retrieve # data every 30 minutes diff --git a/custom_components/octopus_energy/coordinators/intelligent_settings.py b/custom_components/octopus_energy/coordinators/intelligent_settings.py index 9e372c13b..ebbe2bd23 100644 --- a/custom_components/octopus_energy/coordinators/intelligent_settings.py +++ b/custom_components/octopus_energy/coordinators/intelligent_settings.py @@ -107,7 +107,7 @@ async def async_update_intelligent_settings_data(): hass.data[DOMAIN][account_id][DATA_INTELLIGENT_SETTINGS_COORDINATOR] = DataUpdateCoordinator( hass, _LOGGER, - name="intelligent_settings", + name=f"intelligent_settings_{account_id}", update_method=async_update_intelligent_settings_data, update_interval=timedelta(seconds=COORDINATOR_REFRESH_IN_SECONDS), always_update=True diff --git a/custom_components/octopus_energy/coordinators/previous_consumption_and_rates.py b/custom_components/octopus_energy/coordinators/previous_consumption_and_rates.py index cdff2c663..2e8f8bd07 100644 --- a/custom_components/octopus_energy/coordinators/previous_consumption_and_rates.py +++ b/custom_components/octopus_energy/coordinators/previous_consumption_and_rates.py @@ -10,6 +10,7 @@ from ..const import ( COORDINATOR_REFRESH_IN_SECONDS, + DATA_ACCOUNT, DOMAIN, DATA_INTELLIGENT_DISPATCHES, EVENT_ELECTRICITY_PREVIOUS_CONSUMPTION_RATES, @@ -22,9 +23,9 @@ from ..api_client.intelligent_dispatches import IntelligentDispatches from ..utils import private_rates_to_public_rates -from ..intelligent import adjust_intelligent_rates +from ..intelligent import adjust_intelligent_rates, is_intelligent_tariff from ..coordinators.intelligent_dispatches import IntelligentDispatchesCoordinatorResult -from . import BaseCoordinatorResult +from . import BaseCoordinatorResult, get_electricity_meter_tariff_code, get_gas_meter_tariff_code from ..utils.rate_information import get_min_max_average_rates _LOGGER = logging.getLogger(__name__) @@ -40,39 +41,55 @@ def __sort_consumption(consumption_data): class PreviousConsumptionCoordinatorResult(BaseCoordinatorResult): consumption: list rates: list + latest_available_timestamp: datetime standing_charge: float - def __init__(self, last_retrieved: datetime, request_attempts: int, consumption: list, rates: list, standing_charge): + def __init__(self, last_retrieved: datetime, request_attempts: int, consumption: list, rates: list, standing_charge, latest_available_timestamp: datetime = None): super().__init__(last_retrieved, request_attempts, REFRESH_RATE_IN_MINUTES_PREVIOUS_CONSUMPTION) self.consumption = consumption self.rates = rates self.standing_charge = standing_charge + self.latest_available_timestamp = latest_available_timestamp async def async_fetch_consumption_and_rates( previous_data: PreviousConsumptionCoordinatorResult, current: datetime, + account_info, client: OctopusEnergyApiClient, period_from: datetime, period_to: datetime, identifier: str, serial_number: str, is_electricity: bool, - tariff_code: str, is_smart_meter: bool, fire_event: Callable[[str, "dict[str, Any]"], None], - intelligent_dispatches: IntelligentDispatches = None + intelligent_dispatches: IntelligentDispatches = None, + tariff_override = None ): """Fetch the previous consumption and rates""" + if (account_info is None): + return previous_data + if (previous_data == None or current >= previous_data.next_refresh): _LOGGER.debug(f"Retrieving previous consumption data for {'electricity' if is_electricity else 'gas'} {identifier}/{serial_number}...") try: if (is_electricity == True): - [consumption_data, rate_data, standing_charge] = await asyncio.gather( + tariff_code = get_electricity_meter_tariff_code(period_from, account_info, identifier, serial_number) if tariff_override is None else tariff_override + if tariff_code is None: + _LOGGER.error(f"Could not determine tariff code for previous consumption for electricity {identifier}/{serial_number}") + return previous_data + + # We'll calculate the wrong value if we don't have our intelligent dispatches + if is_intelligent_tariff(tariff_code) and intelligent_dispatches is None: + return previous_data + + [consumption_data, latest_consumption_data, rate_data, standing_charge] = await asyncio.gather( client.async_get_electricity_consumption(identifier, serial_number, period_from, period_to), + client.async_get_electricity_consumption(identifier, serial_number, None, None, 1), client.async_get_electricity_rates(tariff_code, is_smart_meter, period_from, period_to), client.async_get_electricity_standing_charge(tariff_code, period_from, period_to) ) @@ -83,8 +100,14 @@ async def async_fetch_consumption_and_rates( intelligent_dispatches.planned, intelligent_dispatches.completed) else: - [consumption_data, rate_data, standing_charge] = await asyncio.gather( + tariff_code = get_gas_meter_tariff_code(period_from, account_info, identifier, serial_number) if tariff_override is None else tariff_override + if tariff_code is None: + _LOGGER.error(f"Could not determine tariff code for previous consumption for gas {identifier}/{serial_number}") + return previous_data + + [consumption_data, latest_consumption_data, rate_data, standing_charge] = await asyncio.gather( client.async_get_gas_consumption(identifier, serial_number, period_from, period_to), + client.async_get_gas_consumption(identifier, serial_number, None, None, 1), client.async_get_gas_rates(tariff_code, period_from, period_to), client.async_get_gas_standing_charge(tariff_code, period_from, period_to) ) @@ -108,7 +131,8 @@ async def async_fetch_consumption_and_rates( 1, consumption_data, rate_data, - standing_charge["value_inc_vat"] + standing_charge["value_inc_vat"], + latest_consumption_data[-1]["end"] if latest_consumption_data is not None and len(latest_consumption_data) > 0 else None ) return PreviousConsumptionCoordinatorResult( @@ -116,7 +140,10 @@ async def async_fetch_consumption_and_rates( 1, previous_data.consumption if previous_data is not None else None, previous_data.rates if previous_data is not None else None, - previous_data.standing_charge if previous_data is not None else None + previous_data.standing_charge if previous_data is not None else None, + latest_consumption_data[-1]["end"] + if latest_consumption_data is not None and len(latest_consumption_data) > 0 + else previous_data.latest_available_timestamp if previous_data is not None else None ) except Exception as e: if isinstance(e, ApiException) == False: @@ -129,7 +156,8 @@ async def async_fetch_consumption_and_rates( previous_data.request_attempts + 1, previous_data.consumption, previous_data.rates, - previous_data.standing_charge + previous_data.standing_charge, + previous_data.latest_available_timestamp ) _LOGGER.warning(f"Failed to retrieve previous consumption data for {'electricity' if is_electricity else 'gas'} {identifier}/{serial_number} - using cached data. Next attempt at {result.next_refresh}") else: @@ -139,6 +167,7 @@ async def async_fetch_consumption_and_rates( 2, None, None, + None, None ) _LOGGER.warning(f"Failed to retrieve previous consumption data for {'electricity' if is_electricity else 'gas'} {identifier}/{serial_number}. Next attempt at {result.next_refresh}") @@ -154,47 +183,48 @@ async def async_create_previous_consumption_and_rates_coordinator( identifier: str, serial_number: str, is_electricity: bool, - tariff_code: str, is_smart_meter: bool, - days_offset: int): + days_offset: int, + tariff_override = None): """Create reading coordinator""" - previous_consumption_key = f'{identifier}_{serial_number}_previous_consumption_and_rates' + previous_consumption_data_key = f'{identifier}_{serial_number}_previous_consumption_and_rates' async def async_update_data(): """Fetch data from API endpoint.""" period_from = as_utc((now() - timedelta(days=days_offset)).replace(hour=0, minute=0, second=0, microsecond=0)) period_to = period_from + timedelta(days=1) + account_result = hass.data[DOMAIN][account_id][DATA_ACCOUNT] if DATA_ACCOUNT in hass.data[DOMAIN][account_id] else None + account_info = account_result.account if account_result is not None else None dispatches: IntelligentDispatchesCoordinatorResult = hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DISPATCHES] if DATA_INTELLIGENT_DISPATCHES in hass.data[DOMAIN][account_id] else None result = await async_fetch_consumption_and_rates( - hass.data[DOMAIN][account_id][previous_consumption_key] - if previous_consumption_key in hass.data[DOMAIN][account_id] - else None, + hass.data[DOMAIN][account_id][previous_consumption_data_key] if previous_consumption_data_key in hass.data[DOMAIN][account_id] else None, utcnow(), + account_info, client, period_from, period_to, identifier, serial_number, is_electricity, - tariff_code, is_smart_meter, hass.bus.async_fire, - dispatches.dispatches if dispatches is not None else None + dispatches.dispatches if dispatches is not None else None, + tariff_override ) if (result is not None): - hass.data[DOMAIN][account_id][previous_consumption_key] = result + hass.data[DOMAIN][account_id][previous_consumption_data_key] = result - if previous_consumption_key in hass.data[DOMAIN][account_id]: - return hass.data[DOMAIN][account_id][previous_consumption_key] + if previous_consumption_data_key in hass.data[DOMAIN][account_id]: + return hass.data[DOMAIN][account_id][previous_consumption_data_key] else: return None coordinator = DataUpdateCoordinator( hass, _LOGGER, - name=previous_consumption_key, + name=previous_consumption_data_key, update_method=async_update_data, # Because of how we're using the data, we'll update every minute, but we will only actually retrieve # data every 30 minutes diff --git a/custom_components/octopus_energy/coordinators/saving_sessions.py b/custom_components/octopus_energy/coordinators/saving_sessions.py index 69431f579..a7ccb2f5e 100644 --- a/custom_components/octopus_energy/coordinators/saving_sessions.py +++ b/custom_components/octopus_energy/coordinators/saving_sessions.py @@ -2,7 +2,7 @@ from datetime import datetime, timedelta from typing import Callable, Any -from homeassistant.util.dt import (now) +from homeassistant.util.dt import (now, as_local) from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator ) @@ -75,8 +75,9 @@ async def async_refresh_saving_sessions( "account_id": account_id, "event_code": available_event.code, "event_id": available_event.id, - "event_start": available_event.start, - "event_end": available_event.end, + "event_start": as_local(available_event.start), + "event_end": as_local(available_event.end), + "event_duration_in_minutes": available_event.duration_in_minutes, "event_octopoints_per_kwh": available_event.octopoints }) @@ -91,8 +92,9 @@ async def async_refresh_saving_sessions( joined_events.append({ "id": ev.id, - "start": ev.start, - "end": ev.end, + "start": as_local(ev.start), + "end": as_local(ev.end), + "duration_in_minutes": ev.duration_in_minutes, "rewarded_octopoints": ev.octopoints, "octopoints_per_kwh": original_event.octopoints if original_event is not None else None }) @@ -102,8 +104,9 @@ async def async_refresh_saving_sessions( "available_events": list(map(lambda ev: { "id": ev.id, "code": ev.code, - "start": ev.start, - "end": ev.end, + "start": as_local(ev.start), + "end": as_local(ev.end), + "duration_in_minutes": ev.duration_in_minutes, "octopoints_per_kwh": ev.octopoints }, available_events)), "joined_events": joined_events, @@ -164,7 +167,7 @@ async def async_update_saving_sessions(): hass.data[DOMAIN][account_id][DATA_SAVING_SESSIONS_COORDINATOR] = DataUpdateCoordinator( hass, _LOGGER, - name=f"{account_id}_saving_sessions", + name=f"saving_sessions_{account_id}", update_method=async_update_saving_sessions, # Because of how we're using the data, we'll update every minute, but we will only actually retrieve # data every 30 minutes diff --git a/custom_components/octopus_energy/coordinators/wheel_of_fortune.py b/custom_components/octopus_energy/coordinators/wheel_of_fortune.py index 505b766cf..da04a9eca 100644 --- a/custom_components/octopus_energy/coordinators/wheel_of_fortune.py +++ b/custom_components/octopus_energy/coordinators/wheel_of_fortune.py @@ -78,7 +78,7 @@ async def async_update_data(): coordinator = DataUpdateCoordinator( hass, _LOGGER, - name=f"{account_id}_wheel_of_fortune_spins", + name=f"wheel_of_fortune_spins_{account_id}", update_method=async_update_data, # Because of how we're using the data, we'll update every minute, but we will only actually retrieve # data every 30 minutes diff --git a/custom_components/octopus_energy/cost_tracker/__init__.py b/custom_components/octopus_energy/cost_tracker/__init__.py index 32fb679f6..da6367f70 100644 --- a/custom_components/octopus_energy/cost_tracker/__init__.py +++ b/custom_components/octopus_energy/cost_tracker/__init__.py @@ -1,5 +1,9 @@ from datetime import datetime, timedelta +from homeassistant.components.sensor import ( + SensorStateClass, +) + class CostTrackerResult: tracked_consumption_data: list untracked_consumption_data: list @@ -33,8 +37,13 @@ def add_consumption(current: datetime, new_last_reset: datetime, old_last_reset: datetime, is_accumulative_value: bool, - is_tracking: bool): - if is_accumulative_value == False or (new_last_reset is not None and old_last_reset is not None and new_last_reset > old_last_reset): + is_tracking: bool, + state_class: str = None): + if (is_accumulative_value == False or + (new_last_reset is not None and old_last_reset is not None and new_last_reset > old_last_reset) or + # Based on https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes, when the new value is less than the old value + # this represents a reset + (state_class == SensorStateClass.TOTAL_INCREASING and new_value < old_value)): value = new_value elif old_value is not None: value = new_value - old_value @@ -63,4 +72,55 @@ def add_consumption(current: datetime, else: new_untracked_consumption_data = __add_consumption(new_untracked_consumption_data, target_start, target_end, value) - return CostTrackerResult(new_tracked_consumption_data, new_untracked_consumption_data) \ No newline at end of file + return CostTrackerResult(new_tracked_consumption_data, new_untracked_consumption_data) + +class AccumulativeCostTrackerResult: + accumulative_data: list + total_consumption: float + total_cost: float + + def __init__(self, accumulative_data: list, total_consumption: float, total_cost: float): + self.accumulative_data = accumulative_data + self.total_consumption = total_consumption + self.total_cost = total_cost + +def accumulate_cost(current: datetime, accumulative_data: list, new_cost: float, new_consumption: float) -> AccumulativeCostTrackerResult: + start_of_day = current.replace(hour=0, minute=0, second=0, microsecond=0) + + if accumulative_data is None: + accumulative_data = [] + + total_cost = 0 + total_consumption = 0 + is_day_added = False + new_accumulative_data = [] + for item in accumulative_data: + new_item = item.copy() + + if "start" in new_item and new_item["start"] == start_of_day: + new_item["cost"] = new_cost + new_item["consumption"] = new_consumption + is_day_added = True + + if "consumption" in new_item: + total_consumption += new_item["consumption"] + + if "cost" in new_item: + total_cost += new_item["cost"] + + new_accumulative_data.append(new_item) + + if is_day_added == False: + new_accumulative_data.append({ + "start": start_of_day, + "end": start_of_day + timedelta(days=1), + "cost": new_cost, + "consumption": new_consumption, + }) + + total_consumption += new_consumption + total_cost += new_cost + + return AccumulativeCostTrackerResult(new_accumulative_data, total_consumption, total_cost) + + \ No newline at end of file diff --git a/custom_components/octopus_energy/cost_tracker/cost_tracker.py b/custom_components/octopus_energy/cost_tracker/cost_tracker.py index 0f7a80678..36f9c31de 100644 --- a/custom_components/octopus_energy/cost_tracker/cost_tracker.py +++ b/custom_components/octopus_energy/cost_tracker/cost_tracker.py @@ -1,9 +1,10 @@ from datetime import datetime import logging -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity import generate_entity_id -from homeassistant.util.dt import (now, parse_datetime) +from homeassistant.util.dt import (now, parse_datetime, as_local) from homeassistant.helpers.update_coordinator import ( CoordinatorEntity @@ -19,8 +20,6 @@ async_track_state_change_event, ) -from homeassistant.helpers.typing import EventType - from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -30,12 +29,13 @@ CONFIG_COST_ENTITY_ACCUMULATIVE_VALUE, CONFIG_COST_TARGET_ENTITY_ID, CONFIG_COST_NAME, + DOMAIN, ) from ..coordinators.electricity_rates import ElectricityRatesCoordinatorResult from . import add_consumption from ..electricity import calculate_electricity_consumption_and_cost - +from ..utils.rate_information import get_rate_index, get_unique_rates from ..utils.attributes import dict_to_typed_dict _LOGGER = logging.getLogger(__name__) @@ -43,33 +43,48 @@ class OctopusEnergyCostTrackerSensor(CoordinatorEntity, RestoreSensor): """Sensor for calculating the cost for a given sensor.""" - def __init__(self, hass: HomeAssistant, coordinator, config, is_export): + def __init__(self, hass: HomeAssistant, coordinator, config, peak_type = None): """Init sensor.""" # Pass coordinator to base class CoordinatorEntity.__init__(self, coordinator) self._state = None self._config = config - self._is_export = is_export self._attributes = self._config.copy() self._attributes["is_tracking"] = True self._attributes["tracked_charges"] = [] self._attributes["untracked_charges"] = [] - self._is_export = is_export self._last_reset = None + self._peak_type = peak_type self._hass = hass self.entity_id = generate_entity_id("sensor.{}", self.unique_id, hass=hass) + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added. + + This only applies when fist added to the entity registry. + """ + return self._peak_type is None + @property def unique_id(self): """The id of the sensor.""" - return f"octopus_energy_cost_tracker_{self._config[CONFIG_COST_NAME]}" + base_name = f"octopus_energy_cost_tracker_{self._config[CONFIG_COST_NAME]}" + if self._peak_type is not None: + return f"{base_name}_{self._peak_type}" + + return base_name @property def name(self): """Name of the sensor.""" - return f"Octopus Energy Cost Tracker {self._config[CONFIG_COST_NAME]}" + base_name = f"Octopus Energy Cost Tracker {self._config[CONFIG_COST_NAME]}" + if self._peak_type is not None: + return f"{base_name} ({self._peak_type})" + + return base_name @property def device_class(self): @@ -121,7 +136,7 @@ async def async_added_to_hass(self): # Make sure our attributes don't override any changed settings self._attributes.update(self._config) - _LOGGER.debug(f'Restored OctopusEnergyCostTrackerSensor state: {self._state}') + _LOGGER.debug(f'Restored {self.unique_id} state: {self._state}') self.async_on_remove( async_track_state_change_event( @@ -129,71 +144,50 @@ async def async_added_to_hass(self): ) ) - async def _async_calculate_cost(self, event: EventType[EventStateChangedData]): + async def _async_calculate_cost(self, event: Event[EventStateChangedData]): new_state = event.data["new_state"] old_state = event.data["old_state"] - if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + _LOGGER.debug(f"State updated for '{self._config[CONFIG_COST_TARGET_ENTITY_ID]}' for '{self.unique_id}': new_state: {new_state}; old_state: {old_state}") + if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) or old_state is None or old_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): return + + _LOGGER.debug(f'event: {event.data}') current = now() rates_result: ElectricityRatesCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None + new_last_reset = None + if "last_reset" in new_state.attributes and new_state.attributes["last_reset"] is not None: + if isinstance(new_state.attributes["last_reset"], datetime): + new_last_reset = new_state.attributes["last_reset"] + else: + new_last_reset = parse_datetime(new_state.attributes["last_reset"]) + + old_last_reset = None + if "last_reset" in old_state.attributes and old_state.attributes["last_reset"] is not None: + if isinstance(old_state.attributes["last_reset"], datetime): + old_last_reset = old_state.attributes["last_reset"] + else: + old_last_reset = parse_datetime(old_state.attributes["last_reset"]) + consumption_data = add_consumption(current, self._attributes["tracked_charges"], self._attributes["untracked_charges"], float(new_state.state), None if old_state.state is None or old_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) else float(old_state.state), - parse_datetime(new_state.attributes["last_reset"]) if "last_reset" in new_state.attributes and new_state.attributes["last_reset"] is not None else None, - parse_datetime(old_state.attributes["last_reset"]) if "last_reset" in old_state.attributes and old_state.attributes["last_reset"] is not None else None, + new_last_reset, + old_last_reset, self._config[CONFIG_COST_ENTITY_ACCUMULATIVE_VALUE], - self._attributes["is_tracking"]) + self._attributes["is_tracking"], + new_state.attributes["state_class"] if "state_class" in new_state.attributes else None) + + + _LOGGER.debug(f"Consumption calculated for '{self.unique_id}': {consumption_data}") if (consumption_data is not None and rates_result is not None and rates_result.rates is not None): self._reset_if_new_day(current) - tracked_result = calculate_electricity_consumption_and_cost( - current, - consumption_data.tracked_consumption_data, - rates_result.rates, - 0, - None, # We want to always recalculate - rates_result.rates[0]["tariff_code"], - 0, - False - ) - - untracked_result = calculate_electricity_consumption_and_cost( - current, - consumption_data.untracked_consumption_data, - rates_result.rates, - 0, - None, # We want to always recalculate - rates_result.rates[0]["tariff_code"], - 0, - False - ) - - if tracked_result is not None and untracked_result is not None: - self._attributes["tracked_charges"] = list(map(lambda charge: { - "start": charge["start"], - "end": charge["end"], - "rate": charge["rate"], - "consumption": charge["consumption"], - "cost": charge["cost"] - }, tracked_result["charges"])) - - self._attributes["untracked_charges"] = list(map(lambda charge: { - "start": charge["start"], - "end": charge["end"], - "rate": charge["rate"], - "consumption": charge["consumption"], - "cost": charge["cost"] - }, untracked_result["charges"])) - - self._attributes["total_consumption"] = tracked_result["total_consumption"] + untracked_result["total_consumption"] - self._state = tracked_result["total_cost"] - - self.async_write_ha_state() + self._recalculate_cost(current, consumption_data.tracked_consumption_data, consumption_data.untracked_consumption_data, rates_result.rates) @callback async def async_update_cost_tracker_config(self, is_tracking_enabled: bool): @@ -201,9 +195,95 @@ async def async_update_cost_tracker_config(self, is_tracking_enabled: bool): self._attributes["is_tracking"] = is_tracking_enabled self.async_write_ha_state() + + @callback + async def async_reset_cost_tracker(self): + """Resets the sensor""" + self._state = 0 + self._attributes["tracked_charges"] = [] + self._attributes["untracked_charges"] = [] + self._attributes["total_consumption"] = 0 + + self.async_write_ha_state() + _LOGGER.debug(f"Cost tracker '{self.unique_id}' manually reset") + + @callback + async def async_adjust_cost_tracker(self, datetime, consumption: float): + """Adjusts the sensor""" + local_datetime = as_local(datetime) + selected_datetime = None + for data in self._attributes["tracked_charges"]: + if local_datetime >= data["start"] and local_datetime < data["end"]: + selected_datetime = data["start"] + data["consumption"] = consumption + + if selected_datetime is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="cost_tracker_invalid_date", + translation_placeholders={ + "min_date": self._attributes["tracked_charges"][0]["start"].date(), + "max_date": self._attributes["tracked_charges"][-1]["end"].date() + }, + ) + + rates_result: ElectricityRatesCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None + self._recalculate_cost(now(), self._attributes["tracked_charges"], self._attributes["untracked_charges"], rates_result.rates) + _LOGGER.debug(f"Cost tracker '{self.unique_id}' manually adjusted") + + def _recalculate_cost(self, current: datetime, tracked_consumption_data: list, untracked_consumption_data: list, rates: list): + target_rate = None + if self._peak_type is not None: + unique_rates = get_unique_rates(current, rates) + unique_rate_index = get_rate_index(len(unique_rates), self._peak_type) + target_rate = unique_rates[unique_rate_index] if unique_rate_index is not None else None + + tracked_result = calculate_electricity_consumption_and_cost( + tracked_consumption_data, + rates, + 0, + None, # We want to always recalculate + 0, + False, + target_rate=target_rate + ) + + untracked_result = calculate_electricity_consumption_and_cost( + untracked_consumption_data, + rates, + 0, + None, # We want to always recalculate + 0, + False, + target_rate=target_rate + ) + + _LOGGER.debug(f"Cost calculated for '{self.unique_id}'; tracked_result: {tracked_result}; untracked_result: {untracked_result}") + + if tracked_result is not None and untracked_result is not None: + self._attributes["tracked_charges"] = list(map(lambda charge: { + "start": charge["start"], + "end": charge["end"], + "rate": charge["rate"], + "consumption": charge["consumption"], + "cost": charge["cost"] + }, tracked_result["charges"])) + + self._attributes["untracked_charges"] = list(map(lambda charge: { + "start": charge["start"], + "end": charge["end"], + "rate": charge["rate"], + "consumption": charge["consumption"], + "cost": charge["cost"] + }, untracked_result["charges"])) + + self._attributes["total_consumption"] = tracked_result["total_consumption"] + untracked_result["total_consumption"] + self._state = tracked_result["total_cost"] + + self._attributes = dict_to_typed_dict(self._attributes) + self.async_write_ha_state() def _reset_if_new_day(self, current: datetime): - current: datetime = now() start_of_day = current.replace(hour=0, minute=0, second=0, microsecond=0) if self._last_reset is None: self._last_reset = start_of_day @@ -215,6 +295,8 @@ def _reset_if_new_day(self, current: datetime): self._attributes["untracked_charges"] = [] self._attributes["total_consumption"] = 0 self._last_reset = start_of_day + + _LOGGER.debug(f"Cost tracker '{self.unique_id}' reset. self._last_reset: {self._last_reset.date()}; current: {current.date()}") return True diff --git a/custom_components/octopus_energy/electricity/__init__.py b/custom_components/octopus_energy/electricity/__init__.py index f34e0ba83..7904f4de5 100644 --- a/custom_components/octopus_energy/electricity/__init__.py +++ b/custom_components/octopus_energy/electricity/__init__.py @@ -11,15 +11,39 @@ def __sort_consumption(consumption_data): sorted.sort(key=__get_to) return sorted +# class ElectricityConsumptionAndCost: +# standing_charge: float +# total_cost_without_standing_charge: float +# total_cost: float +# total_consumption: float +# last_reset: datetime +# last_evaluated: datetime +# charges: list + +# def __init__(self, +# standing_charge: float, +# total_cost_without_standing_charge: float, +# total_cost: float, +# total_consumption: float, +# last_reset: datetime, +# last_evaluated: datetime, +# charges: list): +# self.standing_charge = standing_charge +# self.total_cost_without_standing_charge = total_cost_without_standing_charge +# self.total_cost = total_cost +# self.total_consumption = total_consumption +# self.last_reset = last_reset +# self.last_evaluated = last_evaluated +# self.charges = charges + def calculate_electricity_consumption_and_cost( - current: datetime, consumption_data, rate_data, standing_charge, last_reset, - tariff_code, minimum_consumption_records = 0, - round_cost = True + round_cost = True, + target_rate = None ): if (consumption_data is not None and len(consumption_data) >= minimum_consumption_records and rate_data is not None and len(rate_data) > 0 and standing_charge is not None): @@ -32,41 +56,34 @@ def calculate_electricity_consumption_and_cost( total_cost_in_pence = 0 total_consumption = 0 - off_peak_cost = get_off_peak_cost(current, rate_data) - total_cost_off_peak = 0 - total_cost_peak = 0 - total_consumption_off_peak = 0 - total_consumption_peak = 0 - for consumption in sorted_consumption_data: consumption_value = consumption["consumption"] consumption_from = consumption["start"] consumption_to = consumption["end"] - total_consumption = total_consumption + consumption_value try: rate = next(r for r in rate_data if r["start"] == consumption_from and r["end"] == consumption_to) except StopIteration: - raise Exception(f"Failed to find rate for consumption between {consumption_from} and {consumption_to} for tariff {tariff_code}") + raise Exception(f"Failed to find rate for consumption between {consumption_from} and {consumption_to}") value = rate["value_inc_vat"] + + if target_rate is not None and value != target_rate: + continue + + total_consumption = total_consumption + consumption_value cost = (value * consumption_value) total_cost_in_pence = total_cost_in_pence + cost - if value == off_peak_cost: - total_consumption_off_peak = total_consumption_off_peak + consumption_value - total_cost_off_peak = total_cost_off_peak + cost - else: - total_consumption_peak = total_consumption_peak + consumption_value - total_cost_peak = total_cost_peak + cost - - charges.append({ + current_charge = { "start": rate["start"], "end": rate["end"], "rate": value_inc_vat_to_pounds(value), "consumption": consumption_value, "cost": round(cost / 100, 2) if round_cost else cost / 100 - }) + } + + charges.append(current_charge) total_cost = round(total_cost_in_pence / 100, 2) if round_cost else total_cost_in_pence / 100 total_cost_plus_standing_charge = round((total_cost_in_pence + standing_charge) / 100, 2) if round_cost else (total_cost_in_pence + standing_charge) / 100 @@ -81,15 +98,9 @@ def calculate_electricity_consumption_and_cost( "total_consumption": total_consumption, "last_reset": last_reset, "last_evaluated": last_calculated_timestamp, - "charges": charges + "charges": charges, } - if off_peak_cost is not None: - result["total_cost_off_peak"] = round(total_cost_off_peak / 100, 2) if round_cost else total_cost_off_peak / 100 - result["total_cost_peak"] = round(total_cost_peak / 100, 2) if round_cost else total_cost_peak / 100 - result["total_consumption_off_peak"] = total_consumption_off_peak - result["total_consumption_peak"] = total_consumption_peak - return result def get_electricity_tariff_override_key(serial_number: str, mpan: str) -> str: diff --git a/custom_components/octopus_energy/electricity/base.py b/custom_components/octopus_energy/electricity/base.py index 532b197fc..adaaf5c7a 100644 --- a/custom_components/octopus_energy/electricity/base.py +++ b/custom_components/octopus_energy/electricity/base.py @@ -17,7 +17,7 @@ def __init__(self, hass: HomeAssistant, meter, point): self._is_export = meter["is_export"] self._is_smart_meter = meter["is_smart_meter"] self._export_id_addition = "_export" if self._is_export == True else "" - self._export_name_addition = " Export" if self._is_export == True else "" + self._export_name_addition = "Export " if self._is_export == True else "" self._attributes = { "mpan": self._mpan, diff --git a/custom_components/octopus_energy/electricity/current_accumulative_consumption.py b/custom_components/octopus_energy/electricity/current_accumulative_consumption.py index 4c3ccf6d0..c7312a8ab 100644 --- a/custom_components/octopus_energy/electricity/current_accumulative_consumption.py +++ b/custom_components/octopus_energy/electricity/current_accumulative_consumption.py @@ -21,6 +21,7 @@ from ..coordinators.current_consumption import CurrentConsumptionCoordinatorResult from .base import (OctopusEnergyElectricitySensor) from ..utils.attributes import dict_to_typed_dict +from ..utils.rate_information import get_peak_name, get_rate_index, get_unique_rates from . import calculate_electricity_consumption_and_cost @@ -29,27 +30,44 @@ class OctopusEnergyCurrentAccumulativeElectricityConsumption(MultiCoordinatorEntity, OctopusEnergyElectricitySensor, RestoreSensor): """Sensor for displaying the current accumulative electricity consumption.""" - def __init__(self, hass: HomeAssistant, coordinator, rates_coordinator, standing_charge_coordinator, tariff_code, meter, point): + def __init__(self, hass: HomeAssistant, coordinator, rates_coordinator, standing_charge_coordinator, meter, point, peak_type = None): """Init sensor.""" MultiCoordinatorEntity.__init__(self, coordinator, [rates_coordinator, standing_charge_coordinator]) - OctopusEnergyElectricitySensor.__init__(self, hass, meter, point) self._state = None self._last_reset = None - self._tariff_code = tariff_code self._rates_coordinator = rates_coordinator self._standing_charge_coordinator = standing_charge_coordinator + self._peak_type = peak_type + + OctopusEnergyElectricitySensor.__init__(self, hass, meter, point) + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added. + + This only applies when fist added to the entity registry. + """ + return self._is_smart_meter and self._peak_type is None @property def unique_id(self): """The id of the sensor.""" - return f"octopus_energy_electricity_{self._serial_number}_{self._mpan}_current_accumulative_consumption" + base_name = f"octopus_energy_electricity_{self._serial_number}_{self._mpan}_current_accumulative_consumption" + if self._peak_type is not None: + return f"{base_name}_{self._peak_type}" + + return base_name @property def name(self): """Name of the sensor.""" - return f"Electricity {self._serial_number} {self._mpan} Current Accumulative Consumption" + base_name = f"Current Accumulative Consumption Electricity ({self._serial_number}/{self._mpan})" + if self._peak_type is not None: + return f"{get_peak_name(self._peak_type)} {base_name}" + + return base_name @property def device_class(self): @@ -80,7 +98,7 @@ def extra_state_attributes(self): def last_reset(self): """Return the time when the sensor was last reset, if any.""" return self._last_reset - + @property def native_value(self): return self._state @@ -94,13 +112,18 @@ def _handle_coordinator_update(self) -> None: rate_data = self._rates_coordinator.data.rates if self._rates_coordinator is not None and self._rates_coordinator.data is not None else None standing_charge = self._standing_charge_coordinator.data.standing_charge["value_inc_vat"] if self._standing_charge_coordinator is not None and self._standing_charge_coordinator.data is not None else None + target_rate = None + if current is not None and self._peak_type is not None: + unique_rates = get_unique_rates(current, rate_data) + unique_rate_index = get_rate_index(len(unique_rates), self._peak_type) + target_rate = unique_rates[unique_rate_index] if unique_rate_index is not None else None + consumption_and_cost = calculate_electricity_consumption_and_cost( - current, consumption_data, rate_data, - standing_charge, + standing_charge if target_rate is None else 0, None, # We want to recalculate - self._tariff_code + target_rate=target_rate ) if (consumption_and_cost is not None): @@ -124,6 +147,7 @@ def _handle_coordinator_update(self) -> None: }, consumption_and_cost["charges"])) } + self._attributes = dict_to_typed_dict(self._attributes) super()._handle_coordinator_update() async def async_added_to_hass(self): @@ -136,4 +160,4 @@ async def async_added_to_hass(self): self._state = None if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) else state.state self._attributes = dict_to_typed_dict(state.attributes) - _LOGGER.debug(f'Restored OctopusEnergyCurrentAccumulativeElectricityConsumption state: {self._state}') \ No newline at end of file + _LOGGER.debug(f'Restored state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/electricity/current_accumulative_cost.py b/custom_components/octopus_energy/electricity/current_accumulative_cost.py index 81e4a2ddf..95eb6f2ab 100644 --- a/custom_components/octopus_energy/electricity/current_accumulative_cost.py +++ b/custom_components/octopus_energy/electricity/current_accumulative_cost.py @@ -23,24 +23,26 @@ from ..coordinators.current_consumption import CurrentConsumptionCoordinatorResult from .base import (OctopusEnergyElectricitySensor) from ..utils.attributes import dict_to_typed_dict +from ..utils.rate_information import get_peak_name, get_rate_index, get_unique_rates _LOGGER = logging.getLogger(__name__) class OctopusEnergyCurrentAccumulativeElectricityCost(MultiCoordinatorEntity, OctopusEnergyElectricitySensor, RestoreSensor): """Sensor for displaying the current days accumulative electricity cost.""" - def __init__(self, hass: HomeAssistant, coordinator, rates_coordinator, standing_charge_coordinator, tariff_code, meter, point): + def __init__(self, hass: HomeAssistant, coordinator, rates_coordinator, standing_charge_coordinator, meter, point, peak_type = None): """Init sensor.""" MultiCoordinatorEntity.__init__(self, coordinator, [rates_coordinator, standing_charge_coordinator]) - OctopusEnergyElectricitySensor.__init__(self, hass, meter, point) self._hass = hass - self._tariff_code = tariff_code self._state = None self._last_reset = None self._rates_coordinator = rates_coordinator self._standing_charge_coordinator = standing_charge_coordinator + self._peak_type = peak_type + + OctopusEnergyElectricitySensor.__init__(self, hass, meter, point) @property def entity_registry_enabled_default(self) -> bool: @@ -48,17 +50,25 @@ def entity_registry_enabled_default(self) -> bool: This only applies when fist added to the entity registry. """ - return self._is_smart_meter + return self._is_smart_meter and self._peak_type is None @property def unique_id(self): """The id of the sensor.""" - return f"octopus_energy_electricity_{self._serial_number}_{self._mpan}{self._export_id_addition}_current_accumulative_cost" + base_name = f"octopus_energy_electricity_{self._serial_number}_{self._mpan}{self._export_id_addition}_current_accumulative_cost" + if self._peak_type is not None: + return f"{base_name}_{self._peak_type}" + + return base_name @property def name(self): """Name of the sensor.""" - return f"Electricity {self._serial_number} {self._mpan}{self._export_name_addition} Current Accumulative Cost" + base_name = f"Current Accumulative Cost {self._export_name_addition}Electricity ({self._serial_number}/{self._mpan})" + if self._peak_type is not None: + return f"{get_peak_name(self._peak_type)} {base_name}" + + return base_name @property def device_class(self): @@ -103,13 +113,18 @@ def _handle_coordinator_update(self) -> None: rate_data = self._rates_coordinator.data.rates if self._rates_coordinator is not None and self._rates_coordinator.data is not None else None standing_charge = self._standing_charge_coordinator.data.standing_charge["value_inc_vat"] if self._standing_charge_coordinator is not None and self._standing_charge_coordinator.data is not None else None + target_rate = None + if current is not None and self._peak_type is not None: + unique_rates = get_unique_rates(current, rate_data) + unique_rate_index = get_rate_index(len(unique_rates), self._peak_type) + target_rate = unique_rates[unique_rate_index] if unique_rate_index is not None else None + consumption_and_cost = calculate_electricity_consumption_and_cost( - current, consumption_data, rate_data, - standing_charge, + standing_charge if target_rate is None else 0, None, # We want to always recalculate - self._tariff_code + target_rate=target_rate ) if (consumption_and_cost is not None): @@ -122,9 +137,7 @@ def _handle_coordinator_update(self) -> None: "serial_number": self._serial_number, "is_export": self._is_export, "is_smart_meter": self._is_smart_meter, - "tariff_code": self._tariff_code, - "standing_charge": consumption_and_cost["standing_charge"], - "total_without_standing_charge": consumption_and_cost["total_cost_without_standing_charge"], + "tariff_code": rate_data[0]["tariff_code"], "total": consumption_and_cost["total_cost"], "last_evaluated": consumption_and_cost["last_evaluated"], "data_last_retrieved": consumption_result.last_retrieved if consumption_result is not None else None, @@ -137,6 +150,11 @@ def _handle_coordinator_update(self) -> None: }, consumption_and_cost["charges"])) } + if target_rate is None: + self._attributes["standing_charge"] = consumption_and_cost["standing_charge"] + self._attributes["total_cost_without_standing_charge"] = consumption_and_cost["total_cost_without_standing_charge"] + + self._attributes = dict_to_typed_dict(self._attributes) super()._handle_coordinator_update() async def async_added_to_hass(self): @@ -149,4 +167,4 @@ async def async_added_to_hass(self): self._state = None if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) else state.state self._attributes = dict_to_typed_dict(state.attributes) - _LOGGER.debug(f'Restored OctopusEnergyCurrentAccumulativeElectricityCost state: {self._state}') \ No newline at end of file + _LOGGER.debug(f'Restored state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/electricity/current_consumption.py b/custom_components/octopus_energy/electricity/current_consumption.py index a3dd8f5c6..0c2ac8096 100644 --- a/custom_components/octopus_energy/electricity/current_consumption.py +++ b/custom_components/octopus_energy/electricity/current_consumption.py @@ -50,7 +50,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Electricity {self._serial_number} {self._mpan} Current Consumption" + return f"Current Consumption Electricity ({self._serial_number}/{self._mpan})" @property def device_class(self): @@ -106,6 +106,7 @@ def _handle_coordinator_update(self) -> None: self._attributes["last_evaluated"] = result.last_evaluated self._attributes["data_last_retrieved"] = result.data_last_retrieved + self._attributes = dict_to_typed_dict(self._attributes) _LOGGER.debug('Updated OctopusEnergyCurrentElectricityConsumption') super()._handle_coordinator_update() diff --git a/custom_components/octopus_energy/electricity/current_demand.py b/custom_components/octopus_energy/electricity/current_demand.py index 2ff686b32..c07481919 100644 --- a/custom_components/octopus_energy/electricity/current_demand.py +++ b/custom_components/octopus_energy/electricity/current_demand.py @@ -45,7 +45,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Electricity {self._serial_number} {self._mpan} Current Demand" + return f"Current Demand Electricity ({self._serial_number}/{self._mpan})" @property def device_class(self): @@ -88,6 +88,7 @@ def _handle_coordinator_update(self) -> None: self._attributes["last_evaluated"] = now() self._attributes["data_last_retrieved"] = consumption_result.last_retrieved if consumption_result is not None else None + self._attributes = dict_to_typed_dict(self._attributes) super()._handle_coordinator_update() async def async_added_to_hass(self): diff --git a/custom_components/octopus_energy/electricity/current_rate.py b/custom_components/octopus_energy/electricity/current_rate.py index 8897238d4..13ddc9b8d 100644 --- a/custom_components/octopus_energy/electricity/current_rate.py +++ b/custom_components/octopus_energy/electricity/current_rate.py @@ -7,7 +7,7 @@ ) from homeassistant.core import HomeAssistant, callback -from homeassistant.util.dt import (utcnow) +from homeassistant.util.dt import (now) from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, ) @@ -28,7 +28,7 @@ class OctopusEnergyElectricityCurrentRate(CoordinatorEntity, OctopusEnergyElectricitySensor, RestoreSensor): """Sensor for displaying the current rate.""" - def __init__(self, hass: HomeAssistant, coordinator, meter, point, tariff_code, electricity_price_cap): + def __init__(self, hass: HomeAssistant, coordinator, meter, point, electricity_price_cap): """Init sensor.""" # Pass coordinator to base class CoordinatorEntity.__init__(self, coordinator) @@ -37,14 +37,13 @@ def __init__(self, hass: HomeAssistant, coordinator, meter, point, tariff_code, self._state = None self._last_updated = None self._electricity_price_cap = electricity_price_cap - self._tariff_code = tariff_code self._attributes = { "mpan": self._mpan, "serial_number": self._serial_number, "is_export": self._is_export, "is_smart_meter": self._is_smart_meter, - "tariff": self._tariff_code, + "tariff": None, "start": None, "end": None, "is_capped": None, @@ -62,7 +61,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Electricity {self._serial_number} {self._mpan}{self._export_name_addition} Current Rate" + return f"Current Rate {self._export_name_addition}Electricity ({self._serial_number} {self._mpan})" @property def state_class(self): @@ -97,9 +96,9 @@ def native_value(self): def _handle_coordinator_update(self) -> None: """Retrieve the current rate for the sensor.""" # Find the current rate. We only need to do this every half an hour - current = utcnow() + current = now() rates_result: ElectricityRatesCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None - if (rates_result is not None and (self._last_updated is None or self._last_updated < (current - timedelta(minutes=30)) or (current.minute % 30) == 0)): + if (rates_result is not None): _LOGGER.debug(f"Updating OctopusEnergyElectricityCurrentRate for '{self._mpan}/{self._serial_number}'") rate_information = get_current_rate_information(rates_result.rates, current) @@ -110,7 +109,7 @@ def _handle_coordinator_update(self) -> None: "serial_number": self._serial_number, "is_export": self._is_export, "is_smart_meter": self._is_smart_meter, - "tariff": self._tariff_code, + "tariff": rate_information["current_rate"]["tariff_code"], "start": rate_information["current_rate"]["start"], "end": rate_information["current_rate"]["end"], "is_capped": rate_information["current_rate"]["is_capped"], @@ -127,7 +126,7 @@ def _handle_coordinator_update(self) -> None: "serial_number": self._serial_number, "is_export": self._is_export, "is_smart_meter": self._is_smart_meter, - "tariff": self._tariff_code, + "tariff": None, "start": None, "end": None, "is_capped": None, @@ -148,6 +147,7 @@ def _handle_coordinator_update(self) -> None: self._attributes["data_last_retrieved"] = rates_result.last_retrieved self._attributes["last_evaluated"] = current + self._attributes = dict_to_typed_dict(self._attributes) super()._handle_coordinator_update() async def async_added_to_hass(self): diff --git a/custom_components/octopus_energy/electricity/next_rate.py b/custom_components/octopus_energy/electricity/next_rate.py index 66032f27e..9702b7806 100644 --- a/custom_components/octopus_energy/electricity/next_rate.py +++ b/custom_components/octopus_energy/electricity/next_rate.py @@ -7,7 +7,7 @@ ) from homeassistant.core import HomeAssistant, callback -from homeassistant.util.dt import (utcnow) +from homeassistant.util.dt import (now) from homeassistant.helpers.update_coordinator import ( CoordinatorEntity ) @@ -53,7 +53,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Electricity {self._serial_number} {self._mpan}{self._export_name_addition} Next Rate" + return f"Next Rate {self._export_name_addition}Electricity ({self._serial_number}/{self._mpan})" @property def state_class(self): @@ -88,9 +88,9 @@ def native_value(self): def _handle_coordinator_update(self) -> None: """Retrieve the next rate for the sensor.""" # Find the next rate. We only need to do this every half an hour - current = utcnow() + current = now() rates_result: ElectricityRatesCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None - if (rates_result is not None and (self._last_updated is None or self._last_updated < (current - timedelta(minutes=30)) or (current.minute % 30) == 0)): + if (rates_result is not None): _LOGGER.debug(f"Updating OctopusEnergyElectricityNextRate for '{self._mpan}/{self._serial_number}'") target = current @@ -125,6 +125,7 @@ def _handle_coordinator_update(self) -> None: self._attributes["data_last_retrieved"] = rates_result.last_retrieved self._attributes["last_evaluated"] = current + self._attributes = dict_to_typed_dict(self._attributes) super()._handle_coordinator_update() async def async_added_to_hass(self): diff --git a/custom_components/octopus_energy/electricity/off_peak.py b/custom_components/octopus_energy/electricity/off_peak.py index f62b44841..0aeb412f1 100644 --- a/custom_components/octopus_energy/electricity/off_peak.py +++ b/custom_components/octopus_energy/electricity/off_peak.py @@ -17,7 +17,7 @@ ) from homeassistant.helpers.restore_state import RestoreEntity -from ..utils import is_off_peak +from ..utils import get_off_peak_times, is_off_peak from .base import OctopusEnergyElectricitySensor from ..utils.attributes import dict_to_typed_dict @@ -34,7 +34,12 @@ def __init__(self, hass: HomeAssistant, coordinator, meter, point): OctopusEnergyElectricitySensor.__init__(self, hass, meter, point) self._state = None - self._attributes = {} + self._attributes = { + "current_start": None, + "current_end": None, + "next_start": None, + "next_end": None, + } self._last_updated = None self.entity_id = generate_entity_id("binary_sensor.{}", self.unique_id, hass=hass) @@ -47,7 +52,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Electricity {self._serial_number} {self._mpan}{self._export_name_addition} Off Peak" + return f"Off Peak {self._export_name_addition}Electricity ({self._serial_number}/{self._mpan})" @property def icon(self): @@ -71,10 +76,32 @@ def _handle_coordinator_update(self) -> None: if (rates is not None and (self._last_updated is None or self._last_updated < (current - timedelta(minutes=30)) or (current.minute % 30) == 0)): _LOGGER.debug(f"Updating OctopusEnergyElectricityOffPeak for '{self._mpan}/{self._serial_number}'") - self._state = is_off_peak(current, rates) + self._state = False + self._attributes = { + "current_start": None, + "current_end": None, + "next_start": None, + "next_end": None, + } + + times = get_off_peak_times(current, rates) + if times is not None and len(times) > 0: + time = times.pop(0) + if time.start <= current: + self._attributes["current_start"] = time.start + self._attributes["current_end"] = time.end + self._state = True + + if len(times) > 0: + self._attributes["next_start"] = times[0].start + self._attributes["next_end"] = times[0].end + else: + self._attributes["next_start"] = time.start + self._attributes["next_end"] = time.end self._last_updated = current + self._attributes = dict_to_typed_dict(self._attributes) super()._handle_coordinator_update() async def async_added_to_hass(self): diff --git a/custom_components/octopus_energy/electricity/previous_accumulative_consumption.py b/custom_components/octopus_energy/electricity/previous_accumulative_consumption.py index ac449c16b..d02583150 100644 --- a/custom_components/octopus_energy/electricity/previous_accumulative_consumption.py +++ b/custom_components/octopus_energy/electricity/previous_accumulative_consumption.py @@ -32,22 +32,25 @@ from ..statistics.refresh import async_refresh_previous_electricity_consumption_data from ..api_client import OctopusEnergyApiClient from ..coordinators.previous_consumption_and_rates import PreviousConsumptionCoordinatorResult +from ..utils.rate_information import get_peak_name, get_rate_index, get_unique_rates _LOGGER = logging.getLogger(__name__) class OctopusEnergyPreviousAccumulativeElectricityConsumption(CoordinatorEntity, OctopusEnergyElectricitySensor, RestoreSensor): """Sensor for displaying the previous days accumulative electricity reading.""" - def __init__(self, hass: HomeAssistant, client: OctopusEnergyApiClient, coordinator, tariff_code, meter, point): + def __init__(self, hass: HomeAssistant, client: OctopusEnergyApiClient, coordinator, account_id, meter, point, peak_type = None): """Init sensor.""" CoordinatorEntity.__init__(self, coordinator) - OctopusEnergyElectricitySensor.__init__(self, hass, meter, point) self._client = client + self._account_id = account_id self._state = None - self._tariff_code = tariff_code self._last_reset = None self._hass = hass + self._peak_type = peak_type + + OctopusEnergyElectricitySensor.__init__(self, hass, meter, point) @property def entity_registry_enabled_default(self) -> bool: @@ -55,17 +58,26 @@ def entity_registry_enabled_default(self) -> bool: This only applies when fist added to the entity registry. """ - return self._is_smart_meter + return self._is_smart_meter and self._peak_type is None @property def unique_id(self): """The id of the sensor.""" - return f"octopus_energy_electricity_{self._serial_number}_{self._mpan}{self._export_id_addition}_previous_accumulative_consumption" + base_name = f"octopus_energy_electricity_{self._serial_number}_{self._mpan}{self._export_id_addition}_previous_accumulative_consumption" + if self._peak_type is not None: + return f"{base_name}_{self._peak_type}" + + return base_name @property def name(self): """Name of the sensor.""" - return f"Electricity {self._serial_number} {self._mpan}{self._export_name_addition} Previous Accumulative Consumption" + base_name = f"Previous Accumulative Consumption {self._export_name_addition}Electricity ({self._serial_number}/{self._mpan})" + + if self._peak_type is not None: + return f"{base_name} ({get_peak_name(self._peak_type)})" + + return base_name @property def device_class(self): @@ -118,28 +130,35 @@ async def async_update(self): standing_charge = result.standing_charge if result is not None else None current = consumption_data[0]["start"] if consumption_data is not None and len(consumption_data) > 0 else None + target_rate = None + unique_rates = None + if current is not None and self._peak_type is not None: + unique_rates = get_unique_rates(current, rate_data) + unique_rate_index = get_rate_index(len(unique_rates), self._peak_type) + target_rate = unique_rates[unique_rate_index] if unique_rate_index is not None else None + consumption_and_cost = calculate_electricity_consumption_and_cost( - current, consumption_data, rate_data, - standing_charge, + standing_charge if target_rate is None else 0, self._last_reset, - self._tariff_code + target_rate=target_rate ) if (consumption_and_cost is not None): - _LOGGER.debug(f"Calculated previous electricity consumption for '{self._mpan}/{self._serial_number}'...") - - await async_import_external_statistics_from_consumption( - current, - self._hass, - get_electricity_consumption_statistic_unique_id(self._serial_number, self._mpan, self._is_export), - self.name, - consumption_and_cost["charges"], - rate_data, - UnitOfEnergy.KILO_WATT_HOUR, - "consumption" - ) + _LOGGER.debug(f"Calculated previous electricity consumption for '{self._mpan}/{self._serial_number}' ({self.unique_id})...") + + if self._peak_type is None: + await async_import_external_statistics_from_consumption( + current, + self._hass, + get_electricity_consumption_statistic_unique_id(self._serial_number, self._mpan, self._is_export), + self.name, + consumption_and_cost["charges"], + rate_data, + UnitOfEnergy.KILO_WATT_HOUR, + "consumption" + ) self._state = consumption_and_cost["total_consumption"] self._last_reset = consumption_and_cost["last_reset"] @@ -161,6 +180,9 @@ async def async_update(self): if result is not None: self._attributes["data_last_retrieved"] = result.last_retrieved + self._attributes["latest_available_data_timestamp"] = result.latest_available_timestamp + + self._attributes = dict_to_typed_dict(self._attributes) async def async_added_to_hass(self): """Call when entity about to be added to hass.""" @@ -172,7 +194,7 @@ async def async_added_to_hass(self): self._state = None if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) else state.state self._attributes = dict_to_typed_dict(state.attributes) - _LOGGER.debug(f'Restored OctopusEnergyPreviousAccumulativeElectricityConsumption state: {self._state}') + _LOGGER.debug(f'Restored state: {self._state}') @callback async def async_refresh_previous_consumption_data(self, start_date): @@ -181,10 +203,10 @@ async def async_refresh_previous_consumption_data(self, start_date): await async_refresh_previous_electricity_consumption_data( self._hass, self._client, + self._account_id, start_date, self._mpan, self._serial_number, - self._tariff_code, self._is_smart_meter, self._is_export ) \ No newline at end of file diff --git a/custom_components/octopus_energy/electricity/previous_accumulative_cost.py b/custom_components/octopus_energy/electricity/previous_accumulative_cost.py index d55856a0d..47ce16b25 100644 --- a/custom_components/octopus_energy/electricity/previous_accumulative_cost.py +++ b/custom_components/octopus_energy/electricity/previous_accumulative_cost.py @@ -25,6 +25,7 @@ from .base import (OctopusEnergyElectricitySensor) from ..utils.attributes import dict_to_typed_dict from ..coordinators.previous_consumption_and_rates import PreviousConsumptionCoordinatorResult +from ..utils.rate_information import get_peak_name, get_rate_index, get_unique_rates from ..statistics.cost import async_import_external_statistics_from_cost, get_electricity_cost_statistic_unique_id @@ -33,16 +34,16 @@ class OctopusEnergyPreviousAccumulativeElectricityCost(CoordinatorEntity, OctopusEnergyElectricitySensor, RestoreSensor): """Sensor for displaying the previous days accumulative electricity cost.""" - def __init__(self, hass: HomeAssistant, coordinator, tariff_code, meter, point): + def __init__(self, hass: HomeAssistant, coordinator, meter, point, peak_type = None): """Init sensor.""" CoordinatorEntity.__init__(self, coordinator) - OctopusEnergyElectricitySensor.__init__(self, hass, meter, point) self._hass = hass - self._tariff_code = tariff_code - self._state = None self._last_reset = None + self._peak_type = peak_type + + OctopusEnergyElectricitySensor.__init__(self, hass, meter, point) @property def entity_registry_enabled_default(self) -> bool: @@ -50,17 +51,25 @@ def entity_registry_enabled_default(self) -> bool: This only applies when fist added to the entity registry. """ - return self._is_smart_meter + return self._is_smart_meter and self._peak_type is None @property def unique_id(self): """The id of the sensor.""" - return f"octopus_energy_electricity_{self._serial_number}_{self._mpan}{self._export_id_addition}_previous_accumulative_cost" + base_name = f"octopus_energy_electricity_{self._serial_number}_{self._mpan}{self._export_id_addition}_previous_accumulative_cost" + if self._peak_type is not None: + return f"{base_name}_{self._peak_type}" + + return base_name @property def name(self): """Name of the sensor.""" - return f"Electricity {self._serial_number} {self._mpan}{self._export_name_addition} Previous Accumulative Cost" + base_id = f"Previous Accumulative Cost {self._export_name_addition}Electricity ({self._serial_number}/{self._mpan})" + if self._peak_type is not None: + return f"{base_id} ({get_peak_name(self._peak_type)})" + + return base_id @property def device_class(self): @@ -113,27 +122,34 @@ async def async_update(self): standing_charge = result.standing_charge if result is not None else None current = consumption_data[0]["start"] if consumption_data is not None and len(consumption_data) > 0 else None + target_rate = None + if current is not None and self._peak_type is not None: + unique_rates = get_unique_rates(current, rate_data) + unique_rate_index = get_rate_index(len(unique_rates), self._peak_type) + target_rate = unique_rates[unique_rate_index] if unique_rate_index is not None else None + consumption_and_cost = calculate_electricity_consumption_and_cost( - current, consumption_data, rate_data, - standing_charge, + standing_charge if target_rate is None else 0, self._last_reset, - self._tariff_code + target_rate=target_rate ) if (consumption_and_cost is not None): _LOGGER.debug(f"Calculated previous electricity consumption cost for '{self._mpan}/{self._serial_number}'...") - await async_import_external_statistics_from_cost( - current, - self._hass, - get_electricity_cost_statistic_unique_id(self._serial_number, self._mpan, self._is_export), - self.name, - consumption_and_cost["charges"], - rate_data, - "GBP", - "consumption" - ) + + if self._peak_type is None: + await async_import_external_statistics_from_cost( + current, + self._hass, + get_electricity_cost_statistic_unique_id(self._serial_number, self._mpan, self._is_export), + self.name, + consumption_and_cost["charges"], + rate_data, + "GBP", + "consumption" + ) self._last_reset = consumption_and_cost["last_reset"] self._state = consumption_and_cost["total_cost"] @@ -143,9 +159,7 @@ async def async_update(self): "serial_number": self._serial_number, "is_export": self._is_export, "is_smart_meter": self._is_smart_meter, - "tariff_code": self._tariff_code, - "standing_charge": consumption_and_cost["standing_charge"], - "total_without_standing_charge": consumption_and_cost["total_cost_without_standing_charge"], + "tariff_code": rate_data[0]["tariff_code"], "total": consumption_and_cost["total_cost"], "charges": list(map(lambda charge: { "start": charge["start"], @@ -156,11 +170,17 @@ async def async_update(self): }, consumption_and_cost["charges"])) } + if target_rate is None: + self._attributes["standing_charge"] = consumption_and_cost["standing_charge"] + self._attributes["total_cost_without_standing_charge"] = consumption_and_cost["total_cost_without_standing_charge"] + self._attributes["last_evaluated"] = utcnow() if result is not None: self._attributes["data_last_retrieved"] = result.last_retrieved + self._attributes = dict_to_typed_dict(self._attributes) + async def async_added_to_hass(self): """Call when entity about to be added to hass.""" # If not None, we got an initial value. @@ -171,4 +191,4 @@ async def async_added_to_hass(self): self._state = None if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) else state.state self._attributes = dict_to_typed_dict(state.attributes) - _LOGGER.debug(f'Restored OctopusEnergyPreviousAccumulativeElectricityCost state: {self._state}') \ No newline at end of file + _LOGGER.debug(f'Restored state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/electricity/previous_accumulative_cost_override.py b/custom_components/octopus_energy/electricity/previous_accumulative_cost_override.py index 7c2d37372..67dd96f22 100644 --- a/custom_components/octopus_energy/electricity/previous_accumulative_cost_override.py +++ b/custom_components/octopus_energy/electricity/previous_accumulative_cost_override.py @@ -27,6 +27,7 @@ from ..utils.attributes import dict_to_typed_dict from ..utils.requests import calculate_next_refresh from ..coordinators.previous_consumption_and_rates import PreviousConsumptionCoordinatorResult +from ..utils import private_rates_to_public_rates from ..api_client import (ApiException, OctopusEnergyApiClient) @@ -64,7 +65,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Electricity {self._serial_number} {self._mpan}{self._export_name_addition} Previous Accumulative Cost Override" + return f"Previous Accumulative Cost Override {self._export_name_addition}Electricity ({self._serial_number}/{self._mpan})" @property def entity_registry_enabled_default(self) -> bool: @@ -124,30 +125,31 @@ async def async_update(self): consumption_data = result.consumption if result is not None and result.consumption is not None and len(result.consumption) > 0 else None tariff_override_key = get_electricity_tariff_override_key(self._serial_number, self._mpan) - is_old_data = (result is not None and (self._last_retrieved is None or result.last_retrieved >= self._last_retrieved)) and (self._next_refresh is None or current >= self._next_refresh) + is_old_data = ((result is not None and + (self._last_retrieved is None or result.last_retrieved >= self._last_retrieved)) and + (self._next_refresh is None or current >= self._next_refresh)) is_tariff_present = tariff_override_key in self._hass.data[DOMAIN][self._account_id] has_tariff_changed = is_tariff_present and self._hass.data[DOMAIN][self._account_id][tariff_override_key] != self._tariff_code - if (consumption_data is not None and len(consumption_data) >= MINIMUM_CONSUMPTION_DATA_LENGTH and is_tariff_present and (is_old_data or has_tariff_changed)): - _LOGGER.debug(f"Calculating previous electricity consumption cost override for '{self._mpan}/{self._serial_number}'...") - + if (consumption_data is not None and len(consumption_data) >= MINIMUM_CONSUMPTION_DATA_LENGTH and is_tariff_present and (is_old_data or has_tariff_changed)): tariff_override = self._hass.data[DOMAIN][self._account_id][tariff_override_key] period_from = consumption_data[0]["start"] period_to = consumption_data[-1]["end"] try: + _LOGGER.debug(f"Retrieving rates and standing charge overrides for '{self._mpan}/{self._serial_number}' ({period_from} - {period_to})...") [rate_data, standing_charge] = await asyncio.gather( self._client.async_get_electricity_rates(tariff_override, self._is_smart_meter, period_from, period_to), self._client.async_get_electricity_standing_charge(tariff_override, period_from, period_to) ) + _LOGGER.debug(f"Rates and standing charge overrides for '{self._mpan}/{self._serial_number}' ({period_from} - {period_to}) retrieved") + consumption_and_cost = calculate_electricity_consumption_and_cost( - current, consumption_data, rate_data, standing_charge["value_inc_vat"] if standing_charge is not None else None, - None if has_tariff_changed else self._last_reset, - tariff_override + None ) self._tariff_code = tariff_override @@ -176,19 +178,27 @@ async def async_update(self): }, consumption_and_cost["charges"])) } - self._hass.bus.async_fire(EVENT_ELECTRICITY_PREVIOUS_CONSUMPTION_OVERRIDE_RATES, { "mpan": self._mpan, "serial_number": self._serial_number, "tariff_code": self._tariff_code, "rates": rate_data }) + self._hass.bus.async_fire(EVENT_ELECTRICITY_PREVIOUS_CONSUMPTION_OVERRIDE_RATES, + dict_to_typed_dict({ + "mpan": self._mpan, + "serial_number": self._serial_number, + "tariff_code": self._tariff_code, + "rates": private_rates_to_public_rates(rate_data) + })) self._attributes["last_evaluated"] = current self._request_attempts = 1 self._last_retrieved = current self._next_refresh = calculate_next_refresh(current, self._request_attempts, REFRESH_RATE_IN_MINUTES_PREVIOUS_CONSUMPTION) + else: + _LOGGER.debug(f"Consumption and cost not available for '{self._mpan}/{self._serial_number}' ({self._last_reset})") except Exception as e: if isinstance(e, ApiException) == False: raise self._request_attempts = self._request_attempts + 1 self._next_refresh = calculate_next_refresh( - self._last_retrieved if self._last_retrieved is not None else current, + result.last_retrieved, self._request_attempts, REFRESH_RATE_IN_MINUTES_PREVIOUS_CONSUMPTION ) @@ -197,6 +207,8 @@ async def async_update(self): if result is not None: self._attributes["data_last_retrieved"] = result.last_retrieved + self._attributes = dict_to_typed_dict(self._attributes) + async def async_added_to_hass(self): """Call when entity about to be added to hass.""" # If not None, we got an initial value. diff --git a/custom_components/octopus_energy/electricity/previous_accumulative_cost_override_tariff.py b/custom_components/octopus_energy/electricity/previous_accumulative_cost_override_tariff.py index fdf6a0029..1277e7bbf 100644 --- a/custom_components/octopus_energy/electricity/previous_accumulative_cost_override_tariff.py +++ b/custom_components/octopus_energy/electricity/previous_accumulative_cost_override_tariff.py @@ -55,7 +55,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Electricity {self._serial_number} {self._mpan}{self._export_name_addition} Previous Cost Override Tariff" + return f"Previous Cost Override Tariff {self._export_name_addition}Electricity ({self._serial_number}/{self._mpan})" @property def icon(self): diff --git a/custom_components/octopus_energy/electricity/previous_rate.py b/custom_components/octopus_energy/electricity/previous_rate.py index 332a7cf5f..e391a1f42 100644 --- a/custom_components/octopus_energy/electricity/previous_rate.py +++ b/custom_components/octopus_energy/electricity/previous_rate.py @@ -7,7 +7,7 @@ ) from homeassistant.core import HomeAssistant, callback -from homeassistant.util.dt import (utcnow) +from homeassistant.util.dt import (now) from homeassistant.helpers.update_coordinator import ( CoordinatorEntity ) @@ -53,7 +53,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Electricity {self._serial_number} {self._mpan}{self._export_name_addition} Previous Rate" + return f"Previous Rate {self._export_name_addition}Electricity ({self._serial_number}/{self._mpan})" @property def state_class(self): @@ -88,9 +88,9 @@ def native_value(self): def _handle_coordinator_update(self) -> None: """Retrieve the previous rate.""" # Find the previous rate. We only need to do this every half an hour - current = utcnow() + current = now() rates_result: ElectricityRatesCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None - if (rates_result is not None and (self._last_updated is None or self._last_updated < (current - timedelta(minutes=30)) or (current.minute % 30) == 0)): + if (rates_result is not None): _LOGGER.debug(f"Updating OctopusEnergyElectricityPreviousRate for '{self._mpan}/{self._serial_number}'") target = current @@ -125,6 +125,7 @@ def _handle_coordinator_update(self) -> None: self._attributes["data_last_retrieved"] = rates_result.last_retrieved self._attributes["last_evaluated"] = current + self._attributes = dict_to_typed_dict(self._attributes) super()._handle_coordinator_update() async def async_added_to_hass(self): diff --git a/custom_components/octopus_energy/electricity/rates_current_day.py b/custom_components/octopus_energy/electricity/rates_current_day.py index 89405d98e..1259999d9 100644 --- a/custom_components/octopus_energy/electricity/rates_current_day.py +++ b/custom_components/octopus_energy/electricity/rates_current_day.py @@ -36,7 +36,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Electricity {self._serial_number} {self._mpan}{self._export_name_addition} Current Day Rates" + return f"Current Day Rates {self._export_name_addition}Electricity ({self._serial_number}/{self._mpan})" async def async_added_to_hass(self): """Call when entity about to be added to hass.""" diff --git a/custom_components/octopus_energy/electricity/rates_next_day.py b/custom_components/octopus_energy/electricity/rates_next_day.py index 68f200564..60eb6b602 100644 --- a/custom_components/octopus_energy/electricity/rates_next_day.py +++ b/custom_components/octopus_energy/electricity/rates_next_day.py @@ -36,7 +36,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Electricity {self._serial_number} {self._mpan}{self._export_name_addition} Next Day Rates" + return f"Next Day Rates {self._export_name_addition}Electricity ({self._serial_number}/{self._mpan})" async def async_added_to_hass(self): """Call when entity about to be added to hass.""" diff --git a/custom_components/octopus_energy/electricity/rates_previous_consumption.py b/custom_components/octopus_energy/electricity/rates_previous_consumption.py index a72a5daad..6efdda426 100644 --- a/custom_components/octopus_energy/electricity/rates_previous_consumption.py +++ b/custom_components/octopus_energy/electricity/rates_previous_consumption.py @@ -36,7 +36,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Electricity {self._serial_number} {self._mpan}{self._export_name_addition} Previous Consumption Rates" + return f"Previous Consumption Rates {self._export_name_addition}Electricity ({self._serial_number}/{self._mpan})" @property def entity_registry_enabled_default(self) -> bool: diff --git a/custom_components/octopus_energy/electricity/rates_previous_consumption_override.py b/custom_components/octopus_energy/electricity/rates_previous_consumption_override.py index f3c668536..71a6a6c15 100644 --- a/custom_components/octopus_energy/electricity/rates_previous_consumption_override.py +++ b/custom_components/octopus_energy/electricity/rates_previous_consumption_override.py @@ -36,7 +36,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Electricity {self._serial_number} {self._mpan}{self._export_name_addition} Previous Consumption Override Rates" + return f"Previous Consumption Override Rates {self._export_name_addition}Electricity ({self._serial_number}/{self._mpan})" @property def entity_registry_enabled_default(self) -> bool: diff --git a/custom_components/octopus_energy/electricity/rates_previous_day.py b/custom_components/octopus_energy/electricity/rates_previous_day.py index 98081caac..2ce2771dc 100644 --- a/custom_components/octopus_energy/electricity/rates_previous_day.py +++ b/custom_components/octopus_energy/electricity/rates_previous_day.py @@ -36,7 +36,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Electricity {self._serial_number} {self._mpan}{self._export_name_addition} Previous Day Rates" + return f"Previous Day Rates {self._export_name_addition}Electricity ({self._serial_number}/{self._mpan})" async def async_added_to_hass(self): """Call when entity about to be added to hass.""" diff --git a/custom_components/octopus_energy/electricity/standing_charge.py b/custom_components/octopus_energy/electricity/standing_charge.py index fb20ff0ac..81a7347c7 100644 --- a/custom_components/octopus_energy/electricity/standing_charge.py +++ b/custom_components/octopus_energy/electricity/standing_charge.py @@ -23,13 +23,11 @@ class OctopusEnergyElectricityCurrentStandingCharge(CoordinatorEntity, OctopusEnergyElectricitySensor, RestoreSensor): """Sensor for displaying the current standing charge.""" - def __init__(self, hass: HomeAssistant, coordinator, tariff_code, meter, point): + def __init__(self, hass: HomeAssistant, coordinator, meter, point): """Init sensor.""" super().__init__(coordinator) OctopusEnergyElectricitySensor.__init__(self, hass, meter, point) - self._tariff_code = tariff_code - self._state = None self._latest_date = None @@ -41,7 +39,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f'Electricity {self._serial_number} {self._mpan}{self._export_name_addition} Current Standing Charge' + return f'Current Standing Charge {self._export_name_addition}Electricity ({self._serial_number}/{self._mpan})' @property def device_class(self): @@ -83,7 +81,8 @@ def _handle_coordinator_update(self) -> None: self._attributes["end"] = standard_charge_result.standing_charge["end"] else: self._state = None - + + self._attributes = dict_to_typed_dict(self._attributes) super()._handle_coordinator_update() async def async_added_to_hass(self): diff --git a/custom_components/octopus_energy/gas/__init__.py b/custom_components/octopus_energy/gas/__init__.py index f690d4707..2bca4b128 100644 --- a/custom_components/octopus_energy/gas/__init__.py +++ b/custom_components/octopus_energy/gas/__init__.py @@ -25,7 +25,6 @@ def calculate_gas_consumption_and_cost( rate_data, standing_charge, last_reset, - tariff_code, consumption_units, calorific_value ): @@ -62,7 +61,7 @@ def calculate_gas_consumption_and_cost( try: rate = next(r for r in rate_data if r["start"] == consumption_from and r["end"] == consumption_to) except StopIteration: - raise Exception(f"Failed to find rate for consumption between {consumption_from} and {consumption_to} for tariff {tariff_code}") + raise Exception(f"Failed to find rate for consumption between {consumption_from} and {consumption_to}") value = rate["value_inc_vat"] cost = (value * current_consumption_kwh) diff --git a/custom_components/octopus_energy/gas/current_accumulative_consumption_cubic_meters.py b/custom_components/octopus_energy/gas/current_accumulative_consumption_cubic_meters.py index 4f6adfd2d..09a53173b 100644 --- a/custom_components/octopus_energy/gas/current_accumulative_consumption_cubic_meters.py +++ b/custom_components/octopus_energy/gas/current_accumulative_consumption_cubic_meters.py @@ -27,13 +27,12 @@ class OctopusEnergyCurrentAccumulativeGasConsumptionCubicMeters(MultiCoordinatorEntity, OctopusEnergyGasSensor, RestoreSensor): """Sensor for displaying the current accumulative gas consumption.""" - def __init__(self, hass: HomeAssistant, coordinator, rates_coordinator, standing_charge_coordinator, tariff_code, meter, point, calorific_value): + def __init__(self, hass: HomeAssistant, coordinator, rates_coordinator, standing_charge_coordinator, meter, point, calorific_value): """Init sensor.""" MultiCoordinatorEntity.__init__(self, coordinator, [rates_coordinator, standing_charge_coordinator]) OctopusEnergyGasSensor.__init__(self, hass, meter, point) self._hass = hass - self._tariff_code = tariff_code self._state = None self._last_reset = None @@ -49,7 +48,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Gas {self._serial_number} {self._mprn} Current Accumulative Consumption ({UnitOfVolume.CUBIC_METERS})" + return f"Current Accumulative Consumption ({UnitOfVolume.CUBIC_METERS}) Gas ({self._serial_number}/{self._mprn})" @property def device_class(self): @@ -98,7 +97,6 @@ def _handle_coordinator_update(self) -> None: rate_data, standing_charge, None, # We want to always recalculate - self._tariff_code, "kwh", # Our current sensor always reports in kwh self._calorific_value ) @@ -123,6 +121,7 @@ def _handle_coordinator_update(self) -> None: "calorific_value": self._calorific_value } + self._attributes = dict_to_typed_dict(self._attributes) super()._handle_coordinator_update() async def async_added_to_hass(self): diff --git a/custom_components/octopus_energy/gas/current_accumulative_consumption_kwh.py b/custom_components/octopus_energy/gas/current_accumulative_consumption_kwh.py index bb85df032..008887f36 100644 --- a/custom_components/octopus_energy/gas/current_accumulative_consumption_kwh.py +++ b/custom_components/octopus_energy/gas/current_accumulative_consumption_kwh.py @@ -27,13 +27,12 @@ class OctopusEnergyCurrentAccumulativeGasConsumptionKwh(MultiCoordinatorEntity, OctopusEnergyGasSensor, RestoreSensor): """Sensor for displaying the current accumulative gas consumption.""" - def __init__(self, hass: HomeAssistant, coordinator, rates_coordinator, standing_charge_coordinator, tariff_code, meter, point, calorific_value): + def __init__(self, hass: HomeAssistant, coordinator, rates_coordinator, standing_charge_coordinator, meter, point, calorific_value): """Init sensor.""" MultiCoordinatorEntity.__init__(self, coordinator, [rates_coordinator, standing_charge_coordinator]) OctopusEnergyGasSensor.__init__(self, hass, meter, point) self._hass = hass - self._tariff_code = tariff_code self._state = None self._last_reset = None @@ -49,7 +48,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Gas {self._serial_number} {self._mprn} Current Accumulative Consumption" + return f"Current Accumulative Consumption Gas ({self._serial_number}/{self._mprn})" @property def device_class(self): @@ -98,7 +97,6 @@ def _handle_coordinator_update(self) -> None: rate_data, standing_charge, None, # We want to always recalculate - self._tariff_code, "kwh", # Our current sensor always reports in kwh self._calorific_value ) @@ -123,6 +121,7 @@ def _handle_coordinator_update(self) -> None: "calorific_value": self._calorific_value } + self._attributes = dict_to_typed_dict(self._attributes) super()._handle_coordinator_update() async def async_added_to_hass(self): diff --git a/custom_components/octopus_energy/gas/current_accumulative_cost.py b/custom_components/octopus_energy/gas/current_accumulative_cost.py index 8d0fe22e5..462cafa26 100644 --- a/custom_components/octopus_energy/gas/current_accumulative_cost.py +++ b/custom_components/octopus_energy/gas/current_accumulative_cost.py @@ -26,13 +26,12 @@ class OctopusEnergyCurrentAccumulativeGasCost(MultiCoordinatorEntity, OctopusEnergyGasSensor, RestoreSensor): """Sensor for displaying the current days accumulative gas cost.""" - def __init__(self, hass: HomeAssistant, coordinator, rates_coordinator, standing_charge_coordinator, tariff_code, meter, point, calorific_value): + def __init__(self, hass: HomeAssistant, coordinator, rates_coordinator, standing_charge_coordinator, meter, point, calorific_value): """Init sensor.""" MultiCoordinatorEntity.__init__(self, coordinator, [rates_coordinator, standing_charge_coordinator]) OctopusEnergyGasSensor.__init__(self, hass, meter, point) self._hass = hass - self._tariff_code = tariff_code self._state = None self._last_reset = None @@ -56,7 +55,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Gas {self._serial_number} {self._mprn} Current Accumulative Cost" + return f"Current Accumulative Cost Gas ({self._serial_number}/{self._mprn})" @property def device_class(self): @@ -105,7 +104,6 @@ def _handle_coordinator_update(self) -> None: rate_data, standing_charge, None, # We want to always recalculate - self._tariff_code, "kwh", self._calorific_value ) @@ -118,7 +116,7 @@ def _handle_coordinator_update(self) -> None: self._attributes = { "mprn": self._mprn, "serial_number": self._serial_number, - "tariff_code": self._tariff_code, + "tariff_code": rate_data[0]["tariff_code"], "standing_charge": consumption_and_cost["standing_charge"], "total_without_standing_charge": consumption_and_cost["total_cost_without_standing_charge"], "total": consumption_and_cost["total_cost"], @@ -134,6 +132,7 @@ def _handle_coordinator_update(self) -> None: "calorific_value": self._calorific_value } + self._attributes = dict_to_typed_dict(self._attributes) super()._handle_coordinator_update() async def async_added_to_hass(self): diff --git a/custom_components/octopus_energy/gas/current_consumption.py b/custom_components/octopus_energy/gas/current_consumption.py index b38ab7ae8..589fc0eea 100644 --- a/custom_components/octopus_energy/gas/current_consumption.py +++ b/custom_components/octopus_energy/gas/current_consumption.py @@ -50,7 +50,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Gas {self._serial_number} {self._mprn} Current Consumption" + return f"Current Consumption Gas ({self._serial_number}/{self._mprn})" @property def device_class(self): @@ -106,6 +106,8 @@ def _handle_coordinator_update(self) -> None: self._previous_total_consumption = result.total_consumption self._attributes["last_evaluated"] = current_date self._attributes["data_last_retrieved"] = result.data_last_retrieved + + self._attributes = dict_to_typed_dict(self._attributes) super()._handle_coordinator_update() async def async_added_to_hass(self): diff --git a/custom_components/octopus_energy/gas/current_rate.py b/custom_components/octopus_energy/gas/current_rate.py index 435e5aed7..8821c2f07 100644 --- a/custom_components/octopus_energy/gas/current_rate.py +++ b/custom_components/octopus_energy/gas/current_rate.py @@ -7,7 +7,7 @@ ) from homeassistant.core import HomeAssistant, callback -from homeassistant.util.dt import (utcnow) +from homeassistant.util.dt import (now) from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, ) @@ -27,12 +27,11 @@ class OctopusEnergyGasCurrentRate(CoordinatorEntity, OctopusEnergyGasSensor, RestoreSensor): """Sensor for displaying the current rate.""" - def __init__(self, hass: HomeAssistant, coordinator, tariff_code, meter, point, gas_price_cap): + def __init__(self, hass: HomeAssistant, coordinator, meter, point, gas_price_cap): """Init sensor.""" CoordinatorEntity.__init__(self, coordinator) OctopusEnergyGasSensor.__init__(self, hass, meter, point) - self._tariff_code = tariff_code self._gas_price_cap = gas_price_cap self._state = None @@ -42,7 +41,7 @@ def __init__(self, hass: HomeAssistant, coordinator, tariff_code, meter, point, "mprn": self._mprn, "serial_number": self._serial_number, "is_smart_meter": self._is_smart_meter, - "tariff": self._tariff_code, + "tariff": None, "start": None, "end": None, "is_capped": None, @@ -56,7 +55,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f'Gas {self._serial_number} {self._mprn} Current Rate' + return f'Current Rate Gas ({self._serial_number}/{self._mprn})' @property def state_class(self): @@ -90,9 +89,9 @@ def native_value(self): @callback def _handle_coordinator_update(self) -> None: """Retrieve the current rate for the sensor.""" - current = utcnow() + current = now() rates_result: GasRatesCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None - if (rates_result is not None and (self._last_updated is None or self._last_updated < (current - timedelta(minutes=30)) or (current.minute % 30) == 0)): + if (rates_result is not None): _LOGGER.debug(f"Updating OctopusEnergyGasCurrentRate for '{self._mprn}/{self._serial_number}'") rate_information = get_current_rate_information(rates_result.rates, current) @@ -102,7 +101,7 @@ def _handle_coordinator_update(self) -> None: "mprn": self._mprn, "serial_number": self._serial_number, "is_smart_meter": self._is_smart_meter, - "tariff": self._tariff_code, + "tariff": rate_information["current_rate"]["tariff_code"], "start": rate_information["current_rate"]["start"], "end": rate_information["current_rate"]["end"], "is_capped": rate_information["current_rate"]["is_capped"], @@ -114,7 +113,7 @@ def _handle_coordinator_update(self) -> None: "mprn": self._mprn, "serial_number": self._serial_number, "is_smart_meter": self._is_smart_meter, - "tariff": self._tariff_code, + "tariff": None, "start": None, "end": None, "is_capped": None, @@ -131,6 +130,7 @@ def _handle_coordinator_update(self) -> None: self._attributes["data_last_retrieved"] = rates_result.last_retrieved self._attributes["last_evaluated"] = current + self._attributes = dict_to_typed_dict(self._attributes) super()._handle_coordinator_update() async def async_added_to_hass(self): diff --git a/custom_components/octopus_energy/gas/next_rate.py b/custom_components/octopus_energy/gas/next_rate.py index 7cfdc6acc..5b4fbb026 100644 --- a/custom_components/octopus_energy/gas/next_rate.py +++ b/custom_components/octopus_energy/gas/next_rate.py @@ -7,7 +7,7 @@ ) from homeassistant.core import HomeAssistant, callback -from homeassistant.util.dt import (utcnow) +from homeassistant.util.dt import (now) from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, ) @@ -51,7 +51,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f'Gas {self._serial_number} {self._mprn} Next Rate' + return f'Next Rate Gas ({self._serial_number}/{self._mprn})' @property def state_class(self): @@ -85,9 +85,9 @@ def native_value(self): @callback def _handle_coordinator_update(self) -> None: """Retrieve the next rate for the sensor.""" - current = utcnow() + current = now() rates_result: GasRatesCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None - if (rates_result is not None and (self._last_updated is None or self._last_updated < (current - timedelta(minutes=30)) or (current.minute % 30) == 0)): + if (rates_result is not None): _LOGGER.debug(f"Updating OctopusEnergyGasNextRate for '{self._mprn}/{self._serial_number}'") rate_information = get_next_rate_information(rates_result.rates, current) @@ -119,6 +119,7 @@ def _handle_coordinator_update(self) -> None: self._attributes["data_last_retrieved"] = rates_result.last_retrieved self._attributes["last_evaluated"] = current + self._attributes = dict_to_typed_dict(self._attributes) super()._handle_coordinator_update() async def async_added_to_hass(self): diff --git a/custom_components/octopus_energy/gas/previous_accumulative_consumption_cubic_meters.py b/custom_components/octopus_energy/gas/previous_accumulative_consumption_cubic_meters.py index 8501b8682..1f0376b26 100644 --- a/custom_components/octopus_energy/gas/previous_accumulative_consumption_cubic_meters.py +++ b/custom_components/octopus_energy/gas/previous_accumulative_consumption_cubic_meters.py @@ -37,17 +37,17 @@ class OctopusEnergyPreviousAccumulativeGasConsumptionCubicMeters(CoordinatorEntity, OctopusEnergyGasSensor, RestoreSensor): """Sensor for displaying the previous days accumulative gas reading.""" - def __init__(self, hass: HomeAssistant, client: OctopusEnergyApiClient, coordinator, tariff_code, meter, point, calorific_value): + def __init__(self, hass: HomeAssistant, client: OctopusEnergyApiClient, coordinator, account_id, meter, point, calorific_value): """Init sensor.""" CoordinatorEntity.__init__(self, coordinator) OctopusEnergyGasSensor.__init__(self, hass, meter, point) self._hass = hass self._client = client - self._tariff_code = tariff_code self._native_consumption_units = meter["consumption_units"] self._state = None self._last_reset = None + self._account_id = account_id self._calorific_value = calorific_value @property @@ -66,7 +66,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Gas {self._serial_number} {self._mprn} Previous Accumulative Consumption" + return f"Previous Accumulative Consumption Gas ({self._serial_number}/{self._mprn})" @property def device_class(self): @@ -123,7 +123,6 @@ async def async_update(self): rate_data, standing_charge, self._last_reset, - self._tariff_code, self._native_consumption_units, self._calorific_value ) @@ -139,8 +138,7 @@ async def async_update(self): consumption_and_cost["charges"], rate_data, UnitOfVolume.CUBIC_METERS, - "consumption_m3", - False + "consumption_m3" ) self._state = consumption_and_cost["total_consumption_m3"] @@ -165,7 +163,9 @@ async def async_update(self): if result is not None: self._attributes["data_last_retrieved"] = result.last_retrieved + self._attributes["latest_available_data_timestamp"] = result.latest_available_timestamp + self._attributes = dict_to_typed_dict(self._attributes) super()._handle_coordinator_update() async def async_added_to_hass(self): @@ -182,15 +182,15 @@ async def async_added_to_hass(self): @callback async def async_refresh_previous_consumption_data(self, start_date): - """Update sensors config""" + """Refresh the underlying consumption data""" await async_refresh_previous_gas_consumption_data( self._hass, self._client, + self._account_id, start_date, self._mprn, self._serial_number, - self._tariff_code, self._native_consumption_units, self._calorific_value, ) \ No newline at end of file diff --git a/custom_components/octopus_energy/gas/previous_accumulative_consumption_kwh.py b/custom_components/octopus_energy/gas/previous_accumulative_consumption_kwh.py index 962f8f71d..46084d9ff 100644 --- a/custom_components/octopus_energy/gas/previous_accumulative_consumption_kwh.py +++ b/custom_components/octopus_energy/gas/previous_accumulative_consumption_kwh.py @@ -34,13 +34,12 @@ class OctopusEnergyPreviousAccumulativeGasConsumptionKwh(CoordinatorEntity, OctopusEnergyGasSensor, RestoreSensor): """Sensor for displaying the previous days accumulative gas consumption in kwh.""" - def __init__(self, hass: HomeAssistant, coordinator, tariff_code, meter, point, calorific_value): + def __init__(self, hass: HomeAssistant, coordinator, meter, point, calorific_value): """Init sensor.""" CoordinatorEntity.__init__(self, coordinator) OctopusEnergyGasSensor.__init__(self, hass, meter, point) self._hass = hass - self._tariff_code = tariff_code self._native_consumption_units = meter["consumption_units"] self._state = None self._last_reset = None @@ -62,7 +61,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Gas {self._serial_number} {self._mprn} Previous Accumulative Consumption (kWh)" + return f"Previous Accumulative Consumption (kWh) Gas ({self._serial_number}/{self._mprn})" @property def device_class(self): @@ -119,7 +118,6 @@ async def async_update(self): rate_data, standing_charge, self._last_reset, - self._tariff_code, self._native_consumption_units, self._calorific_value ) @@ -135,8 +133,7 @@ async def async_update(self): consumption_and_cost["charges"], rate_data, UnitOfEnergy.KILO_WATT_HOUR, - "consumption_kwh", - False + "consumption_kwh" ) self._state = consumption_and_cost["total_consumption_kwh"] @@ -159,6 +156,9 @@ async def async_update(self): if result is not None: self._attributes["data_last_retrieved"] = result.last_retrieved + self._attributes["latest_available_data_timestamp"] = result.latest_available_timestamp + + self._attributes = dict_to_typed_dict(self._attributes) async def async_added_to_hass(self): """Call when entity about to be added to hass.""" diff --git a/custom_components/octopus_energy/gas/previous_accumulative_cost.py b/custom_components/octopus_energy/gas/previous_accumulative_cost.py index 3e24e8e76..6149e99f7 100644 --- a/custom_components/octopus_energy/gas/previous_accumulative_cost.py +++ b/custom_components/octopus_energy/gas/previous_accumulative_cost.py @@ -33,13 +33,12 @@ class OctopusEnergyPreviousAccumulativeGasCost(CoordinatorEntity, OctopusEnergyGasSensor, RestoreSensor): """Sensor for displaying the previous days accumulative gas cost.""" - def __init__(self, hass: HomeAssistant, coordinator, tariff_code, meter, point, calorific_value): + def __init__(self, hass: HomeAssistant, coordinator, meter, point, calorific_value): """Init sensor.""" CoordinatorEntity.__init__(self, coordinator) OctopusEnergyGasSensor.__init__(self, hass, meter, point) self._hass = hass - self._tariff_code = tariff_code self._native_consumption_units = meter["consumption_units"] self._state = None @@ -62,7 +61,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Gas {self._serial_number} {self._mprn} Previous Accumulative Cost" + return f"Previous Accumulative Cost Gas ({self._serial_number}/{self._mprn})" @property def device_class(self): @@ -120,7 +119,6 @@ async def async_update(self): rate_data, standing_charge, self._last_reset, - self._tariff_code, self._native_consumption_units, self._calorific_value ) @@ -136,8 +134,7 @@ async def async_update(self): consumption_and_cost["charges"], rate_data, "GBP", - "consumption_kwh", - False + "consumption_kwh" ) self._last_reset = consumption_and_cost["last_reset"] @@ -146,7 +143,7 @@ async def async_update(self): self._attributes = { "mprn": self._mprn, "serial_number": self._serial_number, - "tariff_code": self._tariff_code, + "tariff_code": rate_data[0]["tariff_code"], "standing_charge": consumption_and_cost["standing_charge"], "total_without_standing_charge": consumption_and_cost["total_cost_without_standing_charge"], "total": consumption_and_cost["total_cost"], @@ -164,6 +161,8 @@ async def async_update(self): if result is not None: self._attributes["data_last_retrieved"] = result.last_retrieved + + self._attributes = dict_to_typed_dict(self._attributes) async def async_added_to_hass(self): """Call when entity about to be added to hass.""" diff --git a/custom_components/octopus_energy/gas/previous_accumulative_cost_override.py b/custom_components/octopus_energy/gas/previous_accumulative_cost_override.py index 035773b96..d88ed913d 100644 --- a/custom_components/octopus_energy/gas/previous_accumulative_cost_override.py +++ b/custom_components/octopus_energy/gas/previous_accumulative_cost_override.py @@ -30,6 +30,7 @@ from ..utils.attributes import dict_to_typed_dict from ..utils.requests import calculate_next_refresh from ..coordinators.previous_consumption_and_rates import PreviousConsumptionCoordinatorResult +from ..utils import private_rates_to_public_rates from ..const import DOMAIN, EVENT_GAS_PREVIOUS_CONSUMPTION_OVERRIDE_RATES, MINIMUM_CONSUMPTION_DATA_LENGTH, REFRESH_RATE_IN_MINUTES_PREVIOUS_CONSUMPTION @@ -65,7 +66,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Gas {self._serial_number} {self._mprn} Previous Accumulative Cost Override" + return f"Previous Accumulative Cost Override Gas ({self._serial_number}/{self._mprn})" @property def entity_registry_enabled_default(self) -> bool: @@ -130,24 +131,24 @@ async def async_update(self): has_tariff_changed = is_tariff_present and self._hass.data[DOMAIN][self._account_id][tariff_override_key] != self._tariff_code if (consumption_data is not None and len(consumption_data) >= MINIMUM_CONSUMPTION_DATA_LENGTH and is_tariff_present and (is_old_data or has_tariff_changed)): - _LOGGER.debug(f"Calculating previous gas consumption cost override for '{self._mprn}/{self._serial_number}'...") - tariff_override = self._hass.data[DOMAIN][self._account_id][tariff_override_key] period_from = consumption_data[0]["start"] period_to = consumption_data[-1]["end"] try: + _LOGGER.debug(f"Retrieving rates and standing charge overrides for '{self._mprn}/{self._serial_number}' ({period_from} - {period_to})...") [rate_data, standing_charge] = await asyncio.gather( self._client.async_get_gas_rates(tariff_override, period_from, period_to), self._client.async_get_gas_standing_charge(tariff_override, period_from, period_to) ) + _LOGGER.debug(f"Rates and standing charge overrides for '{self._mprn}/{self._serial_number}' ({period_from} - {period_to}) retrieved") + consumption_and_cost = calculate_gas_consumption_and_cost( consumption_data, rate_data, standing_charge["value_inc_vat"] if standing_charge is not None else None, - None if has_tariff_changed else self._last_reset, - tariff_override, + None, self._native_consumption_units, self._calorific_value ) @@ -177,19 +178,27 @@ async def async_update(self): "calorific_value": self._calorific_value } - self._hass.bus.async_fire(EVENT_GAS_PREVIOUS_CONSUMPTION_OVERRIDE_RATES, { "mprn": self._mprn, "serial_number": self._serial_number, "tariff_code": self._tariff_code, "rates": rate_data }) + self._hass.bus.async_fire(EVENT_GAS_PREVIOUS_CONSUMPTION_OVERRIDE_RATES, + dict_to_typed_dict({ + "mprn": self._mprn, + "serial_number": self._serial_number, + "tariff_code": self._tariff_code, + "rates": private_rates_to_public_rates(rate_data) + })) self._attributes["last_evaluated"] = current self._attempts_to_retrieve = 1 self._last_retrieved = current self._next_refresh = calculate_next_refresh(current, self._request_attempts, REFRESH_RATE_IN_MINUTES_PREVIOUS_CONSUMPTION) + else: + _LOGGER.debug(f"Consumption and cost overrides not available for '{self._mprn}/{self._serial_number}' ({self._last_reset})") except Exception as e: if isinstance(e, ApiException) == False: raise self._request_attempts = self._request_attempts + 1 self._next_refresh = calculate_next_refresh( - self._last_retrieved if self._last_retrieved is not None else current, + result.last_retrieved, self._request_attempts, REFRESH_RATE_IN_MINUTES_PREVIOUS_CONSUMPTION ) @@ -198,6 +207,8 @@ async def async_update(self): if result is not None: self._attributes["data_last_retrieved"] = result.last_retrieved + self._attributes = dict_to_typed_dict(self._attributes) + async def async_added_to_hass(self): """Call when entity about to be added to hass.""" # If not None, we got an initial value. diff --git a/custom_components/octopus_energy/gas/previous_accumulative_cost_override_tariff.py b/custom_components/octopus_energy/gas/previous_accumulative_cost_override_tariff.py index 1941a6a05..245bf6636 100644 --- a/custom_components/octopus_energy/gas/previous_accumulative_cost_override_tariff.py +++ b/custom_components/octopus_energy/gas/previous_accumulative_cost_override_tariff.py @@ -55,7 +55,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Gas {self._serial_number} {self._mprn} Previous Cost Override Tariff" + return f"Previous Cost Override Tariff Gas ({self._serial_number}/{self._mprn})" @property def icon(self): diff --git a/custom_components/octopus_energy/gas/previous_rate.py b/custom_components/octopus_energy/gas/previous_rate.py index 270c310da..ceb9f95bb 100644 --- a/custom_components/octopus_energy/gas/previous_rate.py +++ b/custom_components/octopus_energy/gas/previous_rate.py @@ -7,7 +7,7 @@ ) from homeassistant.core import HomeAssistant, callback -from homeassistant.util.dt import (utcnow) +from homeassistant.util.dt import (now) from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, ) @@ -51,7 +51,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f'Gas {self._serial_number} {self._mprn} Previous Rate' + return f'Previous Rate Gas ({self._serial_number}/{self._mprn})' @property def state_class(self): @@ -85,9 +85,9 @@ def native_value(self): @callback def _handle_coordinator_update(self) -> None: """Retrieve the previous rate for the sensor.""" - current = utcnow() + current = now() rates_result: GasRatesCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None - if (rates_result is not None and (self._last_updated is None or self._last_updated < (current - timedelta(minutes=30)) or (current.minute % 30) == 0)): + if (rates_result is not None): _LOGGER.debug(f"Updating OctopusEnergyGasPreviousRate for '{self._mprn}/{self._serial_number}'") rate_information = get_previous_rate_information(rates_result.rates, current) @@ -119,6 +119,7 @@ def _handle_coordinator_update(self) -> None: self._attributes["data_last_retrieved"] = rates_result.last_retrieved self._attributes["last_evaluated"] = current + self._attributes = dict_to_typed_dict(self._attributes) super()._handle_coordinator_update() async def async_added_to_hass(self): diff --git a/custom_components/octopus_energy/gas/rates_current_day.py b/custom_components/octopus_energy/gas/rates_current_day.py index d162640e2..a7f994fce 100644 --- a/custom_components/octopus_energy/gas/rates_current_day.py +++ b/custom_components/octopus_energy/gas/rates_current_day.py @@ -36,7 +36,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Gas {self._serial_number} {self._mprn} Current Day Rates" + return f"Current Day Rates Gas ({self._serial_number}/{self._mprn})" async def async_added_to_hass(self): """Call when entity about to be added to hass.""" diff --git a/custom_components/octopus_energy/gas/rates_next_day.py b/custom_components/octopus_energy/gas/rates_next_day.py index 7c281273d..a4e4dd422 100644 --- a/custom_components/octopus_energy/gas/rates_next_day.py +++ b/custom_components/octopus_energy/gas/rates_next_day.py @@ -36,7 +36,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Gas {self._serial_number} {self._mprn} Next Day Rates" + return f"Next Day Rates Gas ({self._serial_number}/{self._mprn})" async def async_added_to_hass(self): """Call when entity about to be added to hass.""" diff --git a/custom_components/octopus_energy/gas/rates_previous_consumption.py b/custom_components/octopus_energy/gas/rates_previous_consumption.py index 45f3d8c1b..54e4d5872 100644 --- a/custom_components/octopus_energy/gas/rates_previous_consumption.py +++ b/custom_components/octopus_energy/gas/rates_previous_consumption.py @@ -36,7 +36,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Gas {self._serial_number} {self._mprn} Previous Consumption Rates" + return f"Previous Consumption Rates Gas ({self._serial_number}/{self._mprn})" @property def entity_registry_enabled_default(self) -> bool: diff --git a/custom_components/octopus_energy/gas/rates_previous_consumption_override.py b/custom_components/octopus_energy/gas/rates_previous_consumption_override.py index 47c845fad..fcb154dc2 100644 --- a/custom_components/octopus_energy/gas/rates_previous_consumption_override.py +++ b/custom_components/octopus_energy/gas/rates_previous_consumption_override.py @@ -44,7 +44,7 @@ def entity_registry_enabled_default(self) -> bool: @property def name(self): """Name of the sensor.""" - return f"Gas {self._serial_number} {self._mprn} Previous Consumption Override Rates" + return f"Previous Consumption Override Rates Gas ({self._serial_number}/{self._mprn})" async def async_added_to_hass(self): """Call when entity about to be added to hass.""" diff --git a/custom_components/octopus_energy/gas/rates_previous_day.py b/custom_components/octopus_energy/gas/rates_previous_day.py index 93713c1d8..a6abb9c2d 100644 --- a/custom_components/octopus_energy/gas/rates_previous_day.py +++ b/custom_components/octopus_energy/gas/rates_previous_day.py @@ -36,7 +36,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Gas {self._serial_number} {self._mprn} Previous Day Rates" + return f"Previous Day Rates Gas ({self._serial_number}/{self._mprn})" async def async_added_to_hass(self): """Call when entity about to be added to hass.""" diff --git a/custom_components/octopus_energy/gas/standing_charge.py b/custom_components/octopus_energy/gas/standing_charge.py index 3c3fd4b21..ae5cfa982 100644 --- a/custom_components/octopus_energy/gas/standing_charge.py +++ b/custom_components/octopus_energy/gas/standing_charge.py @@ -24,13 +24,11 @@ class OctopusEnergyGasCurrentStandingCharge(CoordinatorEntity, OctopusEnergyGasSensor, RestoreSensor): """Sensor for displaying the current standing charge.""" - def __init__(self, hass: HomeAssistant, coordinator, tariff_code, meter, point): + def __init__(self, hass: HomeAssistant, coordinator, meter, point): """Init sensor.""" CoordinatorEntity.__init__(self, coordinator) OctopusEnergyGasSensor.__init__(self, hass, meter, point) - self._tariff_code = tariff_code - self._state = None self._latest_date = None @@ -42,7 +40,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f'Gas {self._serial_number} {self._mprn} Current Standing Charge' + return f'Current Standing Charge Gas ({self._serial_number}/{self._mprn})' @property def state_class(self): @@ -90,6 +88,7 @@ def _handle_coordinator_update(self) -> None: else: self._state = None + self._attributes = dict_to_typed_dict(self._attributes) super()._handle_coordinator_update() async def async_added_to_hass(self): diff --git a/custom_components/octopus_energy/intelligent/__init__.py b/custom_components/octopus_energy/intelligent/__init__.py index 0a261923e..f78e9d6ec 100644 --- a/custom_components/octopus_energy/intelligent/__init__.py +++ b/custom_components/octopus_energy/intelligent/__init__.py @@ -6,12 +6,13 @@ from homeassistant.helpers import storage -from ..utils import get_active_tariff_code, get_tariff_parts +from ..utils import OffPeakTime, get_active_tariff_code, get_tariff_parts -from ..const import DOMAIN +from ..const import DOMAIN, INTELLIGENT_SOURCE_BUMP_CHARGE, INTELLIGENT_SOURCE_SMART_CHARGE, REFRESH_RATE_IN_MINUTES_INTELLIGENT from ..api_client.intelligent_settings import IntelligentSettings from ..api_client.intelligent_dispatches import IntelligentDispatchItem, IntelligentDispatches +from ..api_client.intelligent_device import IntelligentDevice mock_intelligent_data_key = "MOCK_INTELLIGENT_DATA" @@ -36,25 +37,49 @@ def mock_intelligent_dispatches() -> IntelligentDispatches: utcnow().replace(hour=19, minute=0, second=0, microsecond=0), utcnow().replace(hour=20, minute=0, second=0, microsecond=0), 1, - "smart-charge", + INTELLIGENT_SOURCE_SMART_CHARGE, "home" ), IntelligentDispatchItem( - utcnow().replace(hour=6, minute=0, second=0, microsecond=0), utcnow().replace(hour=7, minute=0, second=0, microsecond=0), - 1.2, - "smart-charge", + utcnow().replace(hour=8, minute=0, second=0, microsecond=0), + 4.6, + None, "home" ), + IntelligentDispatchItem( - utcnow().replace(hour=7, minute=0, second=0, microsecond=0), - utcnow().replace(hour=8, minute=0, second=0, microsecond=0), + utcnow().replace(hour=12, minute=0, second=0, microsecond=0), + utcnow().replace(hour=13, minute=0, second=0, microsecond=0), 4.6, - "smart-charge", + INTELLIGENT_SOURCE_BUMP_CHARGE, "home" ) ] + # Simulate a dispatch coming in late + if (utcnow() >= utcnow().replace(hour=10, minute=10, second=0, microsecond=0) - timedelta(minutes=REFRESH_RATE_IN_MINUTES_INTELLIGENT)): + dispatches.append( + IntelligentDispatchItem( + utcnow().replace(hour=10, minute=10, second=0, microsecond=0), + utcnow().replace(hour=10, minute=30, second=0, microsecond=0), + 1.2, + INTELLIGENT_SOURCE_SMART_CHARGE, + "home" + ) + ) + + if (utcnow() >= utcnow().replace(hour=18, minute=0, second=0, microsecond=0) - timedelta(minutes=REFRESH_RATE_IN_MINUTES_INTELLIGENT)): + dispatches.append( + IntelligentDispatchItem( + utcnow().replace(hour=18, minute=0, second=0, microsecond=0), + utcnow().replace(hour=18, minute=20, second=0, microsecond=0), + 1.2, + INTELLIGENT_SOURCE_SMART_CHARGE, + "home" + ) + ) + for dispatch in dispatches: if (dispatch.end > utcnow()): planned.append(dispatch) @@ -73,16 +98,16 @@ def mock_intelligent_settings(): ) def mock_intelligent_device(): - return { - "krakenflexDeviceId": "1", - "provider": FULLY_SUPPORTED_INTELLIGENT_PROVIDERS[0], - "vehicleMake": "Tesla", - "vehicleModel": "Model Y", - "vehicleBatterySizeInKwh": 75.0, - "chargePointMake": "MyEnergi", - "chargePointModel": "Zappi", - "chargePointPowerInKw": 6.5 - } + return IntelligentDevice( + "1", + FULLY_SUPPORTED_INTELLIGENT_PROVIDERS[0], + "Tesla", + "Model Y", + 75.0, + "MyEnergi", + "Zappi", + 6.5 + ) def is_intelligent_tariff(tariff_code: str): parts = get_tariff_parts(tariff_code.upper()) @@ -106,7 +131,13 @@ def has_intelligent_tariff(current: datetime, account_info): def __get_dispatch(rate, dispatches: list[IntelligentDispatchItem], expected_source: str): if dispatches is not None: for dispatch in dispatches: - if (expected_source is None or dispatch.source == expected_source) and dispatch.start <= rate["start"] and dispatch.end >= rate["end"]: + # Source as none counts as smart charge - https://forum.octopus.energy/t/pending-and-completed-octopus-intelligent-dispatches/8510/102 + if ((expected_source is None or dispatch.source is None or dispatch.source == expected_source) and + ((dispatch.start <= rate["start"] and dispatch.end >= rate["end"]) or # Rate is within dispatch + (dispatch.start >= rate["start"] and dispatch.start < rate["end"]) or # dispatch starts within rate + (dispatch.end > rate["start"] and dispatch.end <= rate["end"]) # dispatch ends within rate + ) + ): return dispatch return None @@ -120,10 +151,11 @@ def adjust_intelligent_rates(rates, planned_dispatches: list[IntelligentDispatch adjusted_rates.append(rate) continue - if __get_dispatch(rate, planned_dispatches, "smart-charge") is not None or __get_dispatch(rate, completed_dispatches, None) is not None: + if __get_dispatch(rate, planned_dispatches, INTELLIGENT_SOURCE_SMART_CHARGE) is not None or __get_dispatch(rate, completed_dispatches, None) is not None: adjusted_rates.append({ "start": rate["start"], "end": rate["end"], + "tariff_code": rate["tariff_code"], "value_inc_vat": off_peak_rate["value_inc_vat"], "is_capped": rate["is_capped"] if "is_capped" in rate else False, "is_intelligent_adjusted": True @@ -133,17 +165,10 @@ def adjust_intelligent_rates(rates, planned_dispatches: list[IntelligentDispatch return adjusted_rates -def is_in_planned_dispatch(current_date: datetime, dispatches: list[IntelligentDispatchItem]) -> bool: - for dispatch in dispatches: - if (dispatch.start <= current_date and dispatch.end >= current_date): - return True - - return False - def is_in_bump_charge(current_date: datetime, dispatches: list[IntelligentDispatchItem]) -> bool: for dispatch in dispatches: - if (dispatch.source == "bump-charge" and dispatch.start <= current_date and dispatch.end >= current_date): - return True + if (dispatch.start <= current_date and dispatch.end >= current_date): + return dispatch.source == INTELLIGENT_SOURCE_BUMP_CHARGE return False @@ -230,4 +255,4 @@ def get_intelligent_features(provider: str) -> IntelligentFeatures: return IntelligentFeatures(False, False, False, False, False) _LOGGER.warning(f"Unexpected intelligent provider '{provider}'") - return IntelligentFeatures(False, False, False, False, False) + return IntelligentFeatures(False, False, False, False, False) \ No newline at end of file diff --git a/custom_components/octopus_energy/intelligent/base.py b/custom_components/octopus_energy/intelligent/base.py index d656134aa..f04d6cab5 100644 --- a/custom_components/octopus_energy/intelligent/base.py +++ b/custom_components/octopus_energy/intelligent/base.py @@ -3,18 +3,19 @@ from ..const import ( DOMAIN, ) +from ..api_client.intelligent_device import IntelligentDevice class OctopusEnergyIntelligentSensor: - def __init__(self, device): + def __init__(self, device: IntelligentDevice): """Init sensor""" self._device = device self._attr_device_info = DeviceInfo( identifiers={ - (DOMAIN, self._device["krakenflexDeviceId"] if "krakenflexDeviceId" in self._device and self._device["krakenflexDeviceId"] is not None else "charger-1") + (DOMAIN, self._device.krakenflexDeviceId if self._device.krakenflexDeviceId is not None else "charger-1") }, name="Charger", connections=set(), - manufacturer=self._device["chargePointMake"], - model=self._device["chargePointModel"] + manufacturer=self._device.chargePointMake, + model=self._device.chargePointModel ) \ No newline at end of file diff --git a/custom_components/octopus_energy/intelligent/bump_charge.py b/custom_components/octopus_energy/intelligent/bump_charge.py index b8e530cf2..c7e6019d4 100644 --- a/custom_components/octopus_energy/intelligent/bump_charge.py +++ b/custom_components/octopus_energy/intelligent/bump_charge.py @@ -46,7 +46,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Octopus Energy {self._account_id} Intelligent Bump Charge" + return f"Intelligent Bump Charge ({self._account_id})" @property def icon(self): @@ -76,6 +76,7 @@ def _handle_coordinator_update(self) -> None: self._state = is_in_bump_charge(current_date, result.dispatches.planned if result.dispatches is not None else []) self._attributes["last_evaluated"] = current_date + self._attributes = dict_to_typed_dict(self._attributes) super()._handle_coordinator_update() async def async_turn_on(self): diff --git a/custom_components/octopus_energy/intelligent/charge_limit.py b/custom_components/octopus_energy/intelligent/charge_limit.py index 12618470b..6eff9254e 100644 --- a/custom_components/octopus_energy/intelligent/charge_limit.py +++ b/custom_components/octopus_energy/intelligent/charge_limit.py @@ -37,6 +37,10 @@ def __init__(self, hass: HomeAssistant, coordinator, client: OctopusEnergyApiCli self._attributes = {} self.entity_id = generate_entity_id("number.{}", self.unique_id, hass=hass) + self._attr_native_min_value = 10 + self._attr_native_max_value = 100 + self._attr_native_step = 5 + @property def unique_id(self): """The id of the sensor.""" @@ -45,7 +49,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Octopus Energy {self._account_id} Intelligent Charge Limit" + return f"Intelligent Charge Limit ({self._account_id})" @property def icon(self): @@ -85,6 +89,7 @@ def _handle_coordinator_update(self) -> None: self._state = settings_result.settings.charge_limit_weekday self._attributes["last_evaluated"] = utcnow() + self._attributes = dict_to_typed_dict(self._attributes) super()._handle_coordinator_update() async def async_set_native_value(self, value: float) -> None: @@ -105,7 +110,7 @@ async def async_added_to_hass(self) -> None: (last_number_data := await self.async_get_last_number_data()) ): - self._attributes = dict_to_typed_dict(last_state.attributes) + self._attributes = dict_to_typed_dict(last_state.attributes, ["min", "max", "step"]) if last_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN): self._state = last_number_data.native_value diff --git a/custom_components/octopus_energy/intelligent/dispatching.py b/custom_components/octopus_energy/intelligent/dispatching.py index 90f8dd981..3c0bee0bb 100644 --- a/custom_components/octopus_energy/intelligent/dispatching.py +++ b/custom_components/octopus_energy/intelligent/dispatching.py @@ -17,25 +17,25 @@ from homeassistant.helpers.restore_state import RestoreEntity from ..intelligent import ( - dispatches_to_dictionary_list, - is_in_planned_dispatch + dispatches_to_dictionary_list ) -from ..utils import is_off_peak - +from ..utils import get_off_peak_times from .base import OctopusEnergyIntelligentSensor from ..coordinators.intelligent_dispatches import IntelligentDispatchesCoordinatorResult from ..utils.attributes import dict_to_typed_dict +from ..api_client.intelligent_device import IntelligentDevice +from ..coordinators import MultiCoordinatorEntity _LOGGER = logging.getLogger(__name__) -class OctopusEnergyIntelligentDispatching(CoordinatorEntity, BinarySensorEntity, OctopusEnergyIntelligentSensor, RestoreEntity): +class OctopusEnergyIntelligentDispatching(MultiCoordinatorEntity, BinarySensorEntity, OctopusEnergyIntelligentSensor, RestoreEntity): """Sensor for determining if an intelligent is dispatching.""" - def __init__(self, hass: HomeAssistant, coordinator, rates_coordinator, mpan: str, device, account_id: str, planned_dispatches_supported: bool): + def __init__(self, hass: HomeAssistant, coordinator, rates_coordinator, mpan: str, device: IntelligentDevice, account_id: str, planned_dispatches_supported: bool): """Init sensor.""" - CoordinatorEntity.__init__(self, coordinator) + MultiCoordinatorEntity.__init__(self, coordinator, [rates_coordinator]) OctopusEnergyIntelligentSensor.__init__(self, device) self._rates_coordinator = rates_coordinator @@ -43,14 +43,7 @@ def __init__(self, hass: HomeAssistant, coordinator, rates_coordinator, mpan: st self._account_id = account_id self._state = None self._planned_dispatches_supported = planned_dispatches_supported - self._attributes = { - "planned_dispatches": [], - "completed_dispatches": [], - "last_evaluated": None, - "provider": device["provider"], - "vehicle_battery_size_in_kwh": device["vehicleBatterySizeInKwh"], - "charge_point_power_in_kw": device["chargePointPowerInKw"] - } + self.__init_attributes__([], [], None, None) self.entity_id = generate_entity_id("binary_sensor.{}", self.unique_id, hass=hass) @@ -62,7 +55,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Octopus Energy {self._account_id} Intelligent Dispatching" + return f"Intelligent Dispatching ({self._account_id})" @property def icon(self): @@ -78,6 +71,21 @@ def extra_state_attributes(self): def is_on(self): return self._state + def __init_attributes__(self, planned_dispatches, completed_dispatches, data_last_retrieved, last_evaluated): + self._attributes = { + "planned_dispatches": planned_dispatches, + "completed_dispatches": completed_dispatches, + "data_last_retrieved": data_last_retrieved, + "last_evaluated": last_evaluated, + "provider": self._device.provider, + "vehicle_battery_size_in_kwh": self._device.vehicleBatterySizeInKwh, + "charge_point_power_in_kw": self._device.chargePointPowerInKw, + "current_start": None, + "current_end": None, + "next_start": None, + "next_end": None, + } + @callback def _handle_coordinator_update(self) -> None: """Determine if OE is currently dispatching energy.""" @@ -86,15 +94,34 @@ def _handle_coordinator_update(self) -> None: current_date = utcnow() planned_dispatches = result.dispatches.planned if result is not None and result.dispatches is not None and self._planned_dispatches_supported else [] - self._state = is_in_planned_dispatch(current_date, planned_dispatches) or is_off_peak(current_date, rates) - - self._attributes = { - "planned_dispatches": dispatches_to_dictionary_list(planned_dispatches) if result is not None else [], - "completed_dispatches": dispatches_to_dictionary_list(result.dispatches.completed if result is not None and result.dispatches is not None else []) if result is not None else [], - "data_last_retrieved": result.last_retrieved if result is not None else None, - "last_evaluated": current_date - } - + + self.__init_attributes__( + dispatches_to_dictionary_list(planned_dispatches) if result is not None else [], + dispatches_to_dictionary_list(result.dispatches.completed if result is not None and result.dispatches is not None else []) if result is not None else [], + result.last_retrieved if result is not None else None, + current_date + ) + + off_peak_times = get_off_peak_times(current_date, rates, True) + is_dispatching = False + + if off_peak_times is not None and len(off_peak_times) > 0: + time = off_peak_times.pop(0) + if time.start <= current_date: + self._attributes["current_start"] = time.start + self._attributes["current_end"] = time.end + is_dispatching = True + + if len(off_peak_times) > 0: + self._attributes["next_start"] = off_peak_times[0].start + self._attributes["next_end"] = off_peak_times[0].end + else: + self._attributes["next_start"] = time.start + self._attributes["next_end"] = time.end + + self._state = is_dispatching + + self._attributes = dict_to_typed_dict(self._attributes) super()._handle_coordinator_update() async def async_added_to_hass(self): diff --git a/custom_components/octopus_energy/intelligent/ready_time.py b/custom_components/octopus_energy/intelligent/ready_time.py index 045ae66fb..4f9bf954e 100644 --- a/custom_components/octopus_energy/intelligent/ready_time.py +++ b/custom_components/octopus_energy/intelligent/ready_time.py @@ -47,7 +47,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Octopus Energy {self._account_id} Intelligent Ready Time" + return f"Intelligent Ready Time ({self._account_id})" @property def icon(self): @@ -77,6 +77,7 @@ def _handle_coordinator_update(self) -> None: self._state = settings_result.settings.ready_time_weekday self._attributes["last_evaluated"] = utcnow() + self._attributes = dict_to_typed_dict(self._attributes) super()._handle_coordinator_update() async def async_set_value(self, value: time) -> None: diff --git a/custom_components/octopus_energy/intelligent/smart_charge.py b/custom_components/octopus_energy/intelligent/smart_charge.py index eb21783a6..cac5e7c55 100644 --- a/custom_components/octopus_energy/intelligent/smart_charge.py +++ b/custom_components/octopus_energy/intelligent/smart_charge.py @@ -45,7 +45,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Octopus Energy {self._account_id} Intelligent Smart Charge" + return f"Intelligent Smart Charge ({self._account_id})" @property def icon(self): @@ -75,6 +75,7 @@ def _handle_coordinator_update(self) -> None: self._state = settings_result.settings.smart_charge self._attributes["last_evaluated"] = utcnow() + self._attributes = dict_to_typed_dict(self._attributes) super()._handle_coordinator_update() async def async_turn_on(self): diff --git a/custom_components/octopus_energy/manifest.json b/custom_components/octopus_energy/manifest.json index e26662469..291924264 100644 --- a/custom_components/octopus_energy/manifest.json +++ b/custom_components/octopus_energy/manifest.json @@ -14,6 +14,6 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/issues", "ssdp": [], - "version": "10.0.4", + "version": "11.0.0", "zeroconf": [] } \ No newline at end of file diff --git a/custom_components/octopus_energy/number.py b/custom_components/octopus_energy/number.py index 561edf331..cdeec3e89 100644 --- a/custom_components/octopus_energy/number.py +++ b/custom_components/octopus_energy/number.py @@ -2,6 +2,7 @@ from .intelligent import get_intelligent_features from .intelligent.charge_limit import OctopusEnergyIntelligentChargeLimit +from .api_client.intelligent_device import IntelligentDevice from .const import ( CONFIG_ACCOUNT_ID, @@ -12,7 +13,6 @@ CONFIG_MAIN_API_KEY, DATA_INTELLIGENT_SETTINGS_COORDINATOR, - DATA_ACCOUNT ) _LOGGER = logging.getLogger(__name__) @@ -38,9 +38,9 @@ async def async_setup_intelligent_sensors(hass, config, async_add_entities): account_id = config[CONFIG_ACCOUNT_ID] client = hass.data[DOMAIN][account_id][DATA_CLIENT] - intelligent_device = hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DEVICE] if DATA_INTELLIGENT_DEVICE in hass.data[DOMAIN][account_id] else None + intelligent_device: IntelligentDevice = hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DEVICE] if DATA_INTELLIGENT_DEVICE in hass.data[DOMAIN][account_id] else None if intelligent_device is not None: - intelligent_features = get_intelligent_features(intelligent_device["provider"]) + intelligent_features = get_intelligent_features(intelligent_device.provider) settings_coordinator = hass.data[DOMAIN][account_id][DATA_INTELLIGENT_SETTINGS_COORDINATOR] if intelligent_features.charge_limit_supported == True: diff --git a/custom_components/octopus_energy/octoplus/points.py b/custom_components/octopus_energy/octoplus/points.py index 1e7a12239..16e2a12c3 100644 --- a/custom_components/octopus_energy/octoplus/points.py +++ b/custom_components/octopus_energy/octoplus/points.py @@ -1,11 +1,13 @@ import logging from datetime import timedelta +import math from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity import generate_entity_id from homeassistant.util.dt import (utcnow) @@ -14,7 +16,7 @@ SensorStateClass ) -from ..const import REFRESH_RATE_IN_MINUTES_OCTOPLUS_WHEEL_OF_FORTUNE +from ..const import DOMAIN, REFRESH_RATE_IN_MINUTES_OCTOPLUS_POINTS from ..utils.requests import calculate_next_refresh from ..api_client import ApiException, OctopusEnergyApiClient, RequestException from ..utils.attributes import dict_to_typed_dict @@ -47,7 +49,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Octopus Energy {self._account_id} Octoplus Points" + return f"Octoplus Points ({self._account_id})" @property def icon(self): @@ -71,20 +73,10 @@ def state(self): async def async_update(self): now = utcnow() if self._next_refresh is None or now >= self._next_refresh: - try: - self._state = await self._client.async_get_octoplus_points() - self._last_evaluated = now - self._request_attempts = 1 - except Exception as e: - if isinstance(e, ApiException) == False: - raise - _LOGGER.warning(f"Failed to retrieve octopoints") - self._request_attempts = self._request_attempts + 1 - - self._next_refresh = calculate_next_refresh(self._last_evaluated, self._request_attempts, REFRESH_RATE_IN_MINUTES_OCTOPLUS_WHEEL_OF_FORTUNE) + await self.async_refresh_points() - self._attributes["data_last_retrieved"] = self._last_evaluated - self._attributes["last_evaluated"] = self._last_evaluated + self._attributes["last_evaluated"] = now + self._attributes = dict_to_typed_dict(self._attributes) async def async_added_to_hass(self): """Call when entity about to be added to hass.""" @@ -96,4 +88,42 @@ async def async_added_to_hass(self): self._state = None if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) else state.state self._attributes = dict_to_typed_dict(state.attributes) - _LOGGER.debug(f'Restored OctopusEnergyOctoplusPoints state: {self._state}') \ No newline at end of file + _LOGGER.debug(f'Restored OctopusEnergyOctoplusPoints state: {self._state}') + + @callback + async def async_redeem_points_into_account_credit(self, points_to_redeem: int): + """Redeem points""" + redeemable_points = self._attributes["redeemable_points"] if "redeemable_points" in self._attributes else 0 + if redeemable_points < 1: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="octoplus_points_no_points", + ) + elif points_to_redeem > redeemable_points: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="octoplus_points_maximum_points", + translation_placeholders={ + "redeemable_points": redeemable_points, + }, + ) + + result = await self._client.async_redeem_octoplus_points_into_account_credit(self._account_id, points_to_redeem) + if result.is_successful: + await self.async_refresh_points() + + async def async_refresh_points(self): + now = utcnow() + try: + self._state = await self._client.async_get_octoplus_points() + self._attributes["redeemable_points"] = math.floor(self._state / 8) * 8 if self._state is not None else 0 + self._last_evaluated = now + self._request_attempts = 1 + except Exception as e: + if isinstance(e, ApiException) == False: + raise + _LOGGER.warning(f"Failed to retrieve octopoints") + self._request_attempts = self._request_attempts + 1 + + self._next_refresh = calculate_next_refresh(self._last_evaluated, self._request_attempts, REFRESH_RATE_IN_MINUTES_OCTOPLUS_POINTS) + self._attributes["data_last_retrieved"] = self._last_evaluated \ No newline at end of file diff --git a/custom_components/octopus_energy/octoplus/saving_sessions.py b/custom_components/octopus_energy/octoplus/saving_sessions.py index a38ab0a71..b555272e1 100644 --- a/custom_components/octopus_energy/octoplus/saving_sessions.py +++ b/custom_components/octopus_energy/octoplus/saving_sessions.py @@ -58,7 +58,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Octopus Energy {self._account_id} Octoplus Saving Session" + return f"Octoplus Saving Session ({self._account_id})" @property def icon(self): @@ -112,6 +112,7 @@ def _handle_coordinator_update(self) -> None: self._attributes["next_joined_event_duration_in_minutes"] = next_event.duration_in_minutes self._attributes["last_evaluated"] = current_date + self._attributes = dict_to_typed_dict(self._attributes) super()._handle_coordinator_update() async def async_added_to_hass(self): diff --git a/custom_components/octopus_energy/octoplus/saving_sessions_events.py b/custom_components/octopus_energy/octoplus/saving_sessions_events.py index d8e1d916d..e520f2790 100644 --- a/custom_components/octopus_energy/octoplus/saving_sessions_events.py +++ b/custom_components/octopus_energy/octoplus/saving_sessions_events.py @@ -37,7 +37,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Octopus Energy {self._account_id} Octoplus Saving Session Events" + return f"Octoplus Saving Session Events ({self._account_id})" async def async_added_to_hass(self): """Call when entity about to be added to hass.""" diff --git a/custom_components/octopus_energy/sensor.py b/custom_components/octopus_energy/sensor.py index 41c1914d1..3342362bb 100644 --- a/custom_components/octopus_energy/sensor.py +++ b/custom_components/octopus_energy/sensor.py @@ -1,26 +1,20 @@ +from datetime import timedelta import voluptuous as vol import logging -from homeassistant.util.dt import (utcnow) -from homeassistant.core import HomeAssistant, SupportsResponse +from homeassistant.util.dt import (utcnow, now) +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform, issue_registry as ir, entity_registry as er +import homeassistant.helpers.config_validation as cv from .electricity.current_consumption import OctopusEnergyCurrentElectricityConsumption from .electricity.current_accumulative_consumption import OctopusEnergyCurrentAccumulativeElectricityConsumption from .electricity.current_accumulative_cost import OctopusEnergyCurrentAccumulativeElectricityCost -from .electricity.current_accumulative_consumption_off_peak import OctopusEnergyCurrentAccumulativeElectricityConsumptionOffPeak -from .electricity.current_accumulative_consumption_peak import OctopusEnergyCurrentAccumulativeElectricityConsumptionPeak -from .electricity.current_accumulative_cost_off_peak import OctopusEnergyCurrentAccumulativeElectricityCostOffPeak -from .electricity.current_accumulative_cost_peak import OctopusEnergyCurrentAccumulativeElectricityCostPeak from .electricity.current_demand import OctopusEnergyCurrentElectricityDemand from .electricity.current_rate import OctopusEnergyElectricityCurrentRate from .electricity.next_rate import OctopusEnergyElectricityNextRate from .electricity.previous_accumulative_consumption import OctopusEnergyPreviousAccumulativeElectricityConsumption -from .electricity.previous_accumulative_consumption_off_peak import OctopusEnergyPreviousAccumulativeElectricityConsumptionOffPeak -from .electricity.previous_accumulative_consumption_peak import OctopusEnergyPreviousAccumulativeElectricityConsumptionPeak from .electricity.previous_accumulative_cost import OctopusEnergyPreviousAccumulativeElectricityCost -from .electricity.previous_accumulative_cost_off_peak import OctopusEnergyPreviousAccumulativeElectricityCostOffPeak -from .electricity.previous_accumulative_cost_peak import OctopusEnergyPreviousAccumulativeElectricityCostPeak from .electricity.previous_accumulative_cost_override import OctopusEnergyPreviousAccumulativeElectricityCostOverride from .electricity.previous_rate import OctopusEnergyElectricityPreviousRate from .electricity.standing_charge import OctopusEnergyElectricityCurrentStandingCharge @@ -39,8 +33,10 @@ from .wheel_of_fortune.electricity_spins import OctopusEnergyWheelOfFortuneElectricitySpins from .wheel_of_fortune.gas_spins import OctopusEnergyWheelOfFortuneGasSpins from .cost_tracker.cost_tracker import OctopusEnergyCostTrackerSensor -from .cost_tracker.cost_tracker_off_peak import OctopusEnergyCostTrackerOffPeakSensor -from .cost_tracker.cost_tracker_peak import OctopusEnergyCostTrackerPeakSensor +from .cost_tracker.cost_tracker_week import OctopusEnergyCostTrackerWeekSensor +from .cost_tracker.cost_tracker_month import OctopusEnergyCostTrackerMonthSensor +from .greenness_forecast.current_index import OctopusEnergyGreennessForecastCurrentIndex +from .greenness_forecast.next_index import OctopusEnergyGreennessForecastNextIndex from .coordinators.current_consumption import async_create_current_consumption_coordinator from .coordinators.gas_rates import async_setup_gas_rates_coordinator @@ -49,6 +45,11 @@ from .coordinators.gas_standing_charges import async_setup_gas_standing_charges_coordinator from .coordinators.wheel_of_fortune import async_setup_wheel_of_fortune_spins_coordinator +from .api_client import OctopusEnergyApiClient +from .utils.tariff_overrides import async_get_tariff_override +from .utils.tariff_cache import async_get_cached_tariff_total_unique_rates, async_save_cached_tariff_total_unique_rates +from .utils.rate_information import get_peak_type, get_unique_rates, has_peak_rates + from .octoplus.points import OctopusEnergyOctoplusPoints from .utils import (get_active_tariff_code) @@ -65,6 +66,7 @@ CONFIG_MAIN_LIVE_GAS_CONSUMPTION_REFRESH_IN_MINUTES, CONFIG_MAIN_PREVIOUS_ELECTRICITY_CONSUMPTION_DAYS_OFFSET, CONFIG_MAIN_PREVIOUS_GAS_CONSUMPTION_DAYS_OFFSET, + DATA_GREENNESS_FORECAST_COORDINATOR, DOMAIN, CONFIG_MAIN_SUPPORTS_LIVE_CONSUMPTION, @@ -73,13 +75,27 @@ CONFIG_MAIN_GAS_PRICE_CAP, DATA_ELECTRICITY_RATES_COORDINATOR_KEY, - DATA_SAVING_SESSIONS_COORDINATOR, DATA_CLIENT, DATA_ACCOUNT ) _LOGGER = logging.getLogger(__name__) +async def get_unique_electricity_rates(hass, client: OctopusEnergyApiClient, tariff_code: str): + total_unique_rates = await async_get_cached_tariff_total_unique_rates(hass, tariff_code) + if total_unique_rates is None: + current_date = now() + period_from = current_date.replace(hour=0, minute=0, second=0, microsecond=0) + period_to = period_from + timedelta(days=1) + rates = await client.async_get_electricity_rates(tariff_code, True, period_from, period_to) + if rates is None: + raise Exception(f"Failed to retrieve rates for tariff '{tariff_code}'") + + total_unique_rates = len(get_unique_rates(current_date, rates)) + await async_save_cached_tariff_total_unique_rates(hass, tariff_code, total_unique_rates) + + return total_unique_rates + async def async_setup_entry(hass, entry, async_add_entities): """Setup sensors based on our entry""" @@ -113,11 +129,29 @@ async def async_setup_entry(hass, entry, async_add_entities): extra=vol.ALLOW_EXTRA, ), ), - "async_spin_wheel", + "async_redeem_points", # supports_response=SupportsResponse.OPTIONAL ) + + account_id = config[CONFIG_ACCOUNT_ID] + account_result = hass.data[DOMAIN][account_id][DATA_ACCOUNT] + account_info = account_result.account if account_result is not None else None + if account_info["octoplus_enrolled"] == True: + platform.async_register_entity_service( + "redeem_octoplus_points_into_account_credit", + vol.All( + vol.Schema( + { + vol.Required("points_to_redeem"): cv.positive_int, + }, + extra=vol.ALLOW_EXTRA, + ), + ), + "async_redeem_points_into_account_credit", + # supports_response=SupportsResponse.OPTIONAL + ) elif config[CONFIG_KIND] == CONFIG_KIND_COST_TRACKER: - await async_setup_cost_sensors(hass, config, async_add_entities) + await async_setup_cost_sensors(hass, entry, config, async_add_entities) platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( @@ -133,6 +167,46 @@ async def async_setup_entry(hass, entry, async_add_entities): "async_update_cost_tracker_config" ) + platform.async_register_entity_service( + "reset_cost_tracker", + vol.All( + vol.Schema( + {}, + extra=vol.ALLOW_EXTRA, + ), + ), + "async_reset_cost_tracker" + ) + + platform.async_register_entity_service( + "adjust_accumulative_cost_tracker", + vol.All( + vol.Schema( + { + vol.Required("date"): cv.date, + vol.Required("consumption"): cv.positive_float, + vol.Required("cost"): cv.positive_float, + }, + extra=vol.ALLOW_EXTRA, + ), + ), + "async_adjust_accumulative_cost_tracker" + ) + + platform.async_register_entity_service( + "adjust_cost_tracker", + vol.All( + vol.Schema( + { + vol.Required("datetime"): cv.datetime, + vol.Required("consumption"): cv.positive_float, + }, + extra=vol.ALLOW_EXTRA, + ), + ), + "async_adjust_cost_tracker" + ) + async def async_setup_default_sensors(hass: HomeAssistant, config, async_add_entities): account_id = config[CONFIG_ACCOUNT_ID] @@ -142,14 +216,15 @@ async def async_setup_default_sensors(hass: HomeAssistant, config, async_add_ent account_info = account_result.account if account_result is not None else None wheel_of_fortune_coordinator = await async_setup_wheel_of_fortune_spins_coordinator(hass, account_id) + greenness_forecast_coordinator = hass.data[DOMAIN][account_id][DATA_GREENNESS_FORECAST_COORDINATOR] entities = [ OctopusEnergyWheelOfFortuneElectricitySpins(hass, wheel_of_fortune_coordinator, client, account_id), - OctopusEnergyWheelOfFortuneGasSpins(hass, wheel_of_fortune_coordinator, client, account_id) + OctopusEnergyWheelOfFortuneGasSpins(hass, wheel_of_fortune_coordinator, client, account_id), + OctopusEnergyGreennessForecastCurrentIndex(hass, greenness_forecast_coordinator, account_id), + OctopusEnergyGreennessForecastNextIndex(hass, greenness_forecast_coordinator, account_id) ] - - registry = er.async_get(hass) entity_ids_to_migrate = [] if account_info["octoplus_enrolled"] == True: @@ -179,11 +254,12 @@ async def async_setup_default_sensors(hass: HomeAssistant, config, async_add_ent electricity_rate_coordinator = hass.data[DOMAIN][account_id][DATA_ELECTRICITY_RATES_COORDINATOR_KEY.format(mpan, serial_number)] electricity_standing_charges_coordinator = await async_setup_electricity_standing_charges_coordinator(hass, account_id, mpan, serial_number) - entities.append(OctopusEnergyElectricityCurrentRate(hass, electricity_rate_coordinator, meter, point, electricity_tariff_code, electricity_price_cap)) + entities.append(OctopusEnergyElectricityCurrentRate(hass, electricity_rate_coordinator, meter, point, electricity_price_cap)) entities.append(OctopusEnergyElectricityPreviousRate(hass, electricity_rate_coordinator, meter, point)) entities.append(OctopusEnergyElectricityNextRate(hass, electricity_rate_coordinator, meter, point)) - entities.append(OctopusEnergyElectricityCurrentStandingCharge(hass, electricity_standing_charges_coordinator, electricity_tariff_code, meter, point)) + entities.append(OctopusEnergyElectricityCurrentStandingCharge(hass, electricity_standing_charges_coordinator, meter, point)) + tariff_override = await async_get_tariff_override(hass, mpan, serial_number) previous_consumption_coordinator = await async_create_previous_consumption_and_rates_coordinator( hass, account_id, @@ -191,17 +267,21 @@ async def async_setup_default_sensors(hass: HomeAssistant, config, async_add_ent mpan, serial_number, True, - electricity_tariff_code, meter["is_smart_meter"], - previous_electricity_consumption_days_offset + previous_electricity_consumption_days_offset, + tariff_override ) - entities.append(OctopusEnergyPreviousAccumulativeElectricityConsumption(hass, client, previous_consumption_coordinator, electricity_tariff_code, meter, point)) - entities.append(OctopusEnergyPreviousAccumulativeElectricityConsumptionPeak(hass, previous_consumption_coordinator, electricity_tariff_code, meter, point)) - entities.append(OctopusEnergyPreviousAccumulativeElectricityConsumptionOffPeak(hass, previous_consumption_coordinator, electricity_tariff_code, meter, point)) - entities.append(OctopusEnergyPreviousAccumulativeElectricityCost(hass, previous_consumption_coordinator, electricity_tariff_code, meter, point)) - entities.append(OctopusEnergyPreviousAccumulativeElectricityCostPeak(hass, previous_consumption_coordinator, electricity_tariff_code, meter, point)) - entities.append(OctopusEnergyPreviousAccumulativeElectricityCostOffPeak(hass, previous_consumption_coordinator, electricity_tariff_code, meter, point)) + entities.append(OctopusEnergyPreviousAccumulativeElectricityConsumption(hass, client, previous_consumption_coordinator, account_id, meter, point)) + entities.append(OctopusEnergyPreviousAccumulativeElectricityCost(hass, previous_consumption_coordinator, meter, point)) entities.append(OctopusEnergyPreviousAccumulativeElectricityCostOverride(hass, account_id, previous_consumption_coordinator, client, electricity_tariff_code, meter, point)) + + # Create a peak override for each available peak type for our tariff + total_unique_rates = await get_unique_electricity_rates(hass, client, electricity_tariff_code if tariff_override is None else tariff_override) + for unique_rate_index in range(0, total_unique_rates): + peak_type = get_peak_type(total_unique_rates, unique_rate_index) + if peak_type is not None: + entities.append(OctopusEnergyPreviousAccumulativeElectricityConsumption(hass, client, previous_consumption_coordinator, account_id, meter, point, peak_type)) + entities.append(OctopusEnergyPreviousAccumulativeElectricityCost(hass, previous_consumption_coordinator, meter, point, peak_type)) if meter["is_export"] == False and CONFIG_MAIN_SUPPORTS_LIVE_CONSUMPTION in config and config[CONFIG_MAIN_SUPPORTS_LIVE_CONSUMPTION] == True: live_consumption_refresh_in_minutes = CONFIG_DEFAULT_LIVE_ELECTRICITY_CONSUMPTION_REFRESH_IN_MINUTES @@ -211,14 +291,17 @@ async def async_setup_default_sensors(hass: HomeAssistant, config, async_add_ent if meter["device_id"] is not None and meter["device_id"] != "": consumption_coordinator = await async_create_current_consumption_coordinator(hass, account_id, client, meter["device_id"], live_consumption_refresh_in_minutes) entities.append(OctopusEnergyCurrentElectricityConsumption(hass, consumption_coordinator, meter, point)) - entities.append(OctopusEnergyCurrentAccumulativeElectricityConsumption(hass, consumption_coordinator, electricity_rate_coordinator, electricity_standing_charges_coordinator, electricity_tariff_code, meter, point)) - entities.append(OctopusEnergyCurrentAccumulativeElectricityConsumptionPeak(hass, consumption_coordinator, electricity_rate_coordinator, electricity_standing_charges_coordinator, electricity_tariff_code, meter, point)) - entities.append(OctopusEnergyCurrentAccumulativeElectricityConsumptionOffPeak(hass, consumption_coordinator, electricity_rate_coordinator, electricity_standing_charges_coordinator, electricity_tariff_code, meter, point)) - entities.append(OctopusEnergyCurrentAccumulativeElectricityCost(hass, consumption_coordinator, electricity_rate_coordinator, electricity_standing_charges_coordinator, electricity_tariff_code, meter, point)) - entities.append(OctopusEnergyCurrentAccumulativeElectricityCostPeak(hass, consumption_coordinator, electricity_rate_coordinator, electricity_standing_charges_coordinator, electricity_tariff_code, meter, point)) - entities.append(OctopusEnergyCurrentAccumulativeElectricityCostOffPeak(hass, consumption_coordinator, electricity_rate_coordinator, electricity_standing_charges_coordinator, electricity_tariff_code, meter, point)) + entities.append(OctopusEnergyCurrentAccumulativeElectricityConsumption(hass, consumption_coordinator, electricity_rate_coordinator, electricity_standing_charges_coordinator, meter, point)) + entities.append(OctopusEnergyCurrentAccumulativeElectricityCost(hass, consumption_coordinator, electricity_rate_coordinator, electricity_standing_charges_coordinator, meter, point)) entities.append(OctopusEnergyCurrentElectricityDemand(hass, consumption_coordinator, meter, point)) + # Create a peak override for each available peak type for our tariff + for unique_rate_index in range(0, total_unique_rates): + peak_type = get_peak_type(total_unique_rates, unique_rate_index) + if peak_type is not None: + entities.append(OctopusEnergyCurrentAccumulativeElectricityConsumption(hass, consumption_coordinator, electricity_rate_coordinator, electricity_standing_charges_coordinator, meter, point, peak_type)) + entities.append(OctopusEnergyCurrentAccumulativeElectricityCost(hass, consumption_coordinator, electricity_rate_coordinator, electricity_standing_charges_coordinator, meter, point, peak_type)) + ir.async_delete_issue(hass, DOMAIN, f"octopus_mini_not_valid_electricity_{mpan}_{serial_number}") else: ir.async_create_issue( @@ -265,11 +348,12 @@ async def async_setup_default_sensors(hass: HomeAssistant, config, async_add_ent gas_rate_coordinator = await async_setup_gas_rates_coordinator(hass, account_id, client, mprn, serial_number) gas_standing_charges_coordinator = await async_setup_gas_standing_charges_coordinator(hass, account_id, mprn, serial_number) - entities.append(OctopusEnergyGasCurrentRate(hass, gas_rate_coordinator, gas_tariff_code, meter, point, gas_price_cap)) + entities.append(OctopusEnergyGasCurrentRate(hass, gas_rate_coordinator, meter, point, gas_price_cap)) entities.append(OctopusEnergyGasPreviousRate(hass, gas_rate_coordinator, meter, point)) entities.append(OctopusEnergyGasNextRate(hass, gas_rate_coordinator, meter, point)) - entities.append(OctopusEnergyGasCurrentStandingCharge(hass, gas_standing_charges_coordinator, gas_tariff_code, meter, point)) + entities.append(OctopusEnergyGasCurrentStandingCharge(hass, gas_standing_charges_coordinator, meter, point)) + tariff_override = await async_get_tariff_override(hass, mpan, serial_number) previous_consumption_coordinator = await async_create_previous_consumption_and_rates_coordinator( hass, account_id, @@ -277,13 +361,13 @@ async def async_setup_default_sensors(hass: HomeAssistant, config, async_add_ent mprn, serial_number, False, - gas_tariff_code, None, - previous_gas_consumption_days_offset + previous_gas_consumption_days_offset, + tariff_override ) - entities.append(OctopusEnergyPreviousAccumulativeGasConsumptionCubicMeters(hass, client, previous_consumption_coordinator, gas_tariff_code, meter, point, calorific_value)) - entities.append(OctopusEnergyPreviousAccumulativeGasConsumptionKwh(hass, previous_consumption_coordinator, gas_tariff_code, meter, point, calorific_value)) - entities.append(OctopusEnergyPreviousAccumulativeGasCost(hass, previous_consumption_coordinator, gas_tariff_code, meter, point, calorific_value)) + entities.append(OctopusEnergyPreviousAccumulativeGasConsumptionCubicMeters(hass, client, previous_consumption_coordinator, account_id, meter, point, calorific_value)) + entities.append(OctopusEnergyPreviousAccumulativeGasConsumptionKwh(hass, previous_consumption_coordinator, meter, point, calorific_value)) + entities.append(OctopusEnergyPreviousAccumulativeGasCost(hass, previous_consumption_coordinator, meter, point, calorific_value)) entities.append(OctopusEnergyPreviousAccumulativeGasCostOverride(hass, account_id, previous_consumption_coordinator, client, gas_tariff_code, meter, point, calorific_value)) entity_ids_to_migrate.append({ @@ -299,9 +383,9 @@ async def async_setup_default_sensors(hass: HomeAssistant, config, async_add_ent if meter["device_id"] is not None and meter["device_id"] != "": consumption_coordinator = await async_create_current_consumption_coordinator(hass, account_id, client, meter["device_id"], live_consumption_refresh_in_minutes) entities.append(OctopusEnergyCurrentGasConsumption(hass, consumption_coordinator, meter, point)) - entities.append(OctopusEnergyCurrentAccumulativeGasConsumptionKwh(hass, consumption_coordinator, gas_rate_coordinator, gas_standing_charges_coordinator, gas_tariff_code, meter, point, calorific_value)) - entities.append(OctopusEnergyCurrentAccumulativeGasConsumptionCubicMeters(hass, consumption_coordinator, gas_rate_coordinator, gas_standing_charges_coordinator, gas_tariff_code, meter, point, calorific_value)) - entities.append(OctopusEnergyCurrentAccumulativeGasCost(hass, consumption_coordinator, gas_rate_coordinator, gas_standing_charges_coordinator, gas_tariff_code, meter, point, calorific_value)) + entities.append(OctopusEnergyCurrentAccumulativeGasConsumptionKwh(hass, consumption_coordinator, gas_rate_coordinator, gas_standing_charges_coordinator, meter, point, calorific_value)) + entities.append(OctopusEnergyCurrentAccumulativeGasConsumptionCubicMeters(hass, consumption_coordinator, gas_rate_coordinator, gas_standing_charges_coordinator, meter, point, calorific_value)) + entities.append(OctopusEnergyCurrentAccumulativeGasCost(hass, consumption_coordinator, gas_rate_coordinator, gas_standing_charges_coordinator, meter, point, calorific_value)) entity_ids_to_migrate.append({ "old": f"octopus_energy_gas_{serial_number}_{mprn}_current_accumulative_consumption", @@ -339,28 +423,47 @@ async def async_setup_default_sensors(hass: HomeAssistant, config, async_add_ent async_add_entities(entities) -async def async_setup_cost_sensors(hass: HomeAssistant, config, async_add_entities): +async def async_setup_cost_sensors(hass: HomeAssistant, entry, config, async_add_entities): account_id = config[CONFIG_ACCOUNT_ID] account_result = hass.data[DOMAIN][account_id][DATA_ACCOUNT] account_info = account_result.account if account_result is not None else None + client = hass.data[DOMAIN][account_id][DATA_CLIENT] mpan = config[CONFIG_COST_MPAN] + registry = er.async_get(hass) + now = utcnow() - is_export = False for point in account_info["electricity_meter_points"]: tariff_code = get_active_tariff_code(now, point["agreements"]) if tariff_code is not None: # For backwards compatibility, pick the first applicable meter if point["mpan"] == mpan or mpan is None: for meter in point["meters"]: - is_export = meter["is_export"] serial_number = meter["serial_number"] coordinator = hass.data[DOMAIN][account_id][DATA_ELECTRICITY_RATES_COORDINATOR_KEY.format(mpan, serial_number)] + + sensor = OctopusEnergyCostTrackerSensor(hass, coordinator, config) + sensor_entity_id = registry.async_get_entity_id("sensor", DOMAIN, sensor.unique_id) + entities = [ - OctopusEnergyCostTrackerSensor(hass, coordinator, config, is_export), - OctopusEnergyCostTrackerOffPeakSensor(hass, coordinator, config, is_export), - OctopusEnergyCostTrackerPeakSensor(hass, coordinator, config, is_export) + sensor, + OctopusEnergyCostTrackerWeekSensor(hass, entry, config, sensor_entity_id if sensor_entity_id is not None else sensor.entity_id), + OctopusEnergyCostTrackerMonthSensor(hass, entry, config, sensor_entity_id if sensor_entity_id is not None else sensor.entity_id), ] + + tariff_override = await async_get_tariff_override(hass, mpan, serial_number) + total_unique_rates = await get_unique_electricity_rates(hass, client, tariff_code if tariff_override is None else tariff_override) + if has_peak_rates(total_unique_rates): + for unique_rate_index in range(0, total_unique_rates): + peak_type = get_peak_type(total_unique_rates, unique_rate_index) + if peak_type is not None: + peak_sensor = OctopusEnergyCostTrackerSensor(hass, coordinator, config, peak_type) + peak_sensor_entity_id = registry.async_get_entity_id("sensor", DOMAIN, peak_sensor.unique_id) + + entities.append(peak_sensor) + entities.append(OctopusEnergyCostTrackerWeekSensor(hass, entry, config, peak_sensor_entity_id if peak_sensor_entity_id is not None else f"sensor.{peak_sensor.unique_id}", peak_type)) + entities.append(OctopusEnergyCostTrackerMonthSensor(hass, entry, config, peak_sensor_entity_id if peak_sensor_entity_id is not None else f"sensor.{peak_sensor.unique_id}", peak_type)) + async_add_entities(entities) - return + break \ No newline at end of file diff --git a/custom_components/octopus_energy/services.yaml b/custom_components/octopus_energy/services.yaml index 477a27a53..e7120f7cb 100644 --- a/custom_components/octopus_energy/services.yaml +++ b/custom_components/octopus_energy/services.yaml @@ -30,9 +30,32 @@ update_target_config: The optional offset to apply to the target rate when it starts selector: text: + target_minimum_rate: + name: Minimum rate + description: + The optional minimum rate the selected rates should not go below + example: '0.10' + selector: + text: + target_maximum_rate: + name: Maximum rate + description: + The optional maximum rate the selected rates should not go above + example: '0.10' + selector: + text: + target_weighting: + name: Weighting + description: + The optional weighting that should be applied to the selected rates + example: '1,2,1' + selector: + text: + purge_invalid_external_statistic_ids: name: Purge invalid external statistics description: Removes external statistics for all meters that don't have an active tariff + refresh_previous_consumption_data: name: Refresh previous consumption data description: Refreshes the previous consumption data for a given entity from a given date. @@ -47,6 +70,7 @@ refresh_previous_consumption_data: required: true selector: date: + join_octoplus_saving_session_event: name: Join Octoplus saving session event description: Joins a given Octoplus saving session event. @@ -60,6 +84,7 @@ join_octoplus_saving_session_event: description: The code of the event that is to be joined. selector: text: + spin_wheel_of_fortune: name: Spin wheel of fortune description: Spins the wheel of fortune for a given energy type @@ -68,6 +93,23 @@ spin_wheel_of_fortune: integration: octopus_energy domain: sensor +redeem_octoplus_points_into_account_credit: + name: Redeem octoplus points into account credit + description: Redeems a given number of octoplus points into account credit + target: + entity: + integration: octopus_energy + domain: sensor + fields: + points_to_redeem: + name: Points to redeem + description: The number of points to redeem + selector: + number: + min: 8 + step: 1 + mode: box + update_cost_tracker: name: Update cost tracker description: Updates cost tracker information. @@ -81,3 +123,63 @@ update_cost_tracker: description: Determines if the cost tracker should be enabled or disabled. selector: boolean: + +reset_cost_tracker: + name: Reset cost tracker + description: Resets a given cost tracker back to zero. + target: + entity: + integration: octopus_energy + domain: sensor + +adjust_accumulative_cost_tracker: + name: Adjusts accumulative cost tracker + description: Adjusts a record within an accumulative cost tracker (e.g. sensor.octopus_energy_cost_tracker_{{COST_TRACKER_NAME}}_week or sensor.octopus_energy_cost_tracker_{{COST_TRACKER_NAME}}_month) + target: + entity: + integration: octopus_energy + domain: sensor + fields: + date: + name: Date + description: The date to adjust within the accumulative cost tracker + selector: + date: + cost: + name: Cost + description: The new cost for the selected date + selector: + number: + step: any + mode: box + unit_of_measurement: GBP + consumption: + name: Consumption + description: The new consumption for the selected date + selector: + number: + step: any + mode: box + unit_of_measurement: kWh + +adjust_cost_tracker: + name: Adjusts cost tracker + description: Adjusts a record within todays cost tracker (e.g. sensor.octopus_energy_cost_tracker_{{COST_TRACKER_NAME}}) + target: + entity: + integration: octopus_energy + domain: sensor + fields: + datetime: + name: DateTime + description: The datetime of the period to adjust within the cost tracker + selector: + datetime: + consumption: + name: Consumption + description: The new consumption for the selected date + selector: + number: + step: any + mode: box + unit_of_measurement: kWh diff --git a/custom_components/octopus_energy/statistics/__init__.py b/custom_components/octopus_energy/statistics/__init__.py index 4a41f406e..c493a581c 100644 --- a/custom_components/octopus_energy/statistics/__init__.py +++ b/custom_components/octopus_energy/statistics/__init__.py @@ -1,6 +1,6 @@ import logging from datetime import (datetime, timedelta) -from custom_components.octopus_energy.const import DOMAIN + from homeassistant.core import HomeAssistant from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.models import StatisticData @@ -8,50 +8,38 @@ statistics_during_period ) -from ..utils import get_active_tariff_code, get_off_peak_cost +from ..const import DOMAIN +from ..utils import get_active_tariff_code _LOGGER = logging.getLogger(__name__) -def build_consumption_statistics(current: datetime, consumptions, rates, consumption_key: str, latest_total_sum: float, latest_peak_sum: float, latest_off_peak_sum: float): +def build_consumption_statistics(current: datetime, consumptions, rates, consumption_key: str, latest_total_sum: float, target_rate = None): last_reset = consumptions[0]["start"].replace(minute=0, second=0, microsecond=0) sums = { "total": latest_total_sum, - "peak": latest_peak_sum, - "off_peak": latest_off_peak_sum } states = { "total": 0, - "peak": 0, - "off_peak": 0 } total_statistics = [] - off_peak_statistics = [] - peak_statistics = [] - off_peak_cost = get_off_peak_cost(current, rates) - _LOGGER.debug(f'total_sum: {latest_total_sum}; latest_peak_sum: {latest_peak_sum}; latest_off_peak_sum: {latest_off_peak_sum}; last_reset: {last_reset}; off_peak_cost: {off_peak_cost}') + _LOGGER.debug(f'total_sum: {latest_total_sum}; target_rate: {target_rate}; last_reset: {last_reset};') for index in range(len(consumptions)): consumption = consumptions[index] consumption_from = consumption["start"] consumption_to = consumption["end"] + start = consumption["start"].replace(minute=0, second=0, microsecond=0) try: rate = next(r for r in rates if r["start"] == consumption_from and r["end"] == consumption_to) except StopIteration: raise Exception(f"Failed to find rate for consumption between {consumption_from} and {consumption_to}") - if rate["value_inc_vat"] == off_peak_cost: - sums["off_peak"] += consumption[consumption_key] - states["off_peak"] += consumption[consumption_key] - else: - sums["peak"] += consumption[consumption_key] - states["peak"] += consumption[consumption_key] - - start = consumption["start"].replace(minute=0, second=0, microsecond=0) - sums["total"] += consumption[consumption_key] - states["total"] += consumption[consumption_key] + if target_rate is None or target_rate == rate["value_inc_vat"]: + sums["total"] += consumption[consumption_key] + states["total"] += consumption[consumption_key] _LOGGER.debug(f'index: {index}; start: {start}; sums: {sums}; states: {states}; added: {(index) % 2 == 1}') @@ -65,49 +53,20 @@ def build_consumption_statistics(current: datetime, consumptions, rates, consump ) ) - off_peak_statistics.append( - StatisticData( - start=start, - last_reset=last_reset, - sum=sums["off_peak"], - state=states["off_peak"] - ) - ) - - peak_statistics.append( - StatisticData( - start=start, - last_reset=last_reset, - sum=sums["peak"], - state=states["peak"] - ) - ) - - return { - "total": total_statistics, - "peak": peak_statistics, - "off_peak": off_peak_statistics - } + return total_statistics -def build_cost_statistics(current: datetime, consumptions, rates, consumption_key: str, latest_total_sum: float, latest_peak_sum: float, latest_off_peak_sum: float): +def build_cost_statistics(current: datetime, consumptions, rates, consumption_key: str, latest_total_sum: float, target_rate = None): last_reset = consumptions[0]["start"].replace(minute=0, second=0, microsecond=0) sums = { - "total": latest_total_sum, - "peak": latest_peak_sum, - "off_peak": latest_off_peak_sum + "total": latest_total_sum } states = { - "total": 0, - "peak": 0, - "off_peak": 0 + "total": 0 } total_statistics = [] - off_peak_statistics = [] - peak_statistics = [] - off_peak_cost = get_off_peak_cost(current, rates) - _LOGGER.debug(f'total_sum: {latest_total_sum}; latest_peak_sum: {latest_peak_sum}; latest_off_peak_sum: {latest_off_peak_sum}; last_reset: {last_reset}; off_peak_cost: {off_peak_cost}') + _LOGGER.debug(f'total_sum: {latest_total_sum}; target_rate: {target_rate}; last_reset: {last_reset};') for index in range(len(consumptions)): consumption = consumptions[index] @@ -119,16 +78,10 @@ def build_cost_statistics(current: datetime, consumptions, rates, consumption_ke rate = next(r for r in rates if r["start"] == consumption_from and r["end"] == consumption_to) except StopIteration: raise Exception(f"Failed to find rate for consumption between {consumption_from} and {consumption_to}") - - if rate["value_inc_vat"] == off_peak_cost: - sums["off_peak"] += round((consumption[consumption_key] * rate["value_inc_vat"]) / 100, 2) - states["off_peak"] += round((consumption[consumption_key] * rate["value_inc_vat"]) / 100, 2) - else: - sums["peak"] += round((consumption[consumption_key] * rate["value_inc_vat"]) / 100, 2) - states["peak"] += round((consumption[consumption_key] * rate["value_inc_vat"]) / 100, 2) - - sums["total"] += round((consumption[consumption_key] * rate["value_inc_vat"]) / 100, 2) - states["total"] += round((consumption[consumption_key] * rate["value_inc_vat"]) / 100, 2) + + if target_rate is None or target_rate == rate["value_inc_vat"]: + sums["total"] += round((consumption[consumption_key] * rate["value_inc_vat"]) / 100, 2) + states["total"] += round((consumption[consumption_key] * rate["value_inc_vat"]) / 100, 2) _LOGGER.debug(f'index: {index}; start: {start}; sums: {sums}; states: {states}; added: {(index) % 2 == 1}') @@ -142,29 +95,7 @@ def build_cost_statistics(current: datetime, consumptions, rates, consumption_ke ) ) - off_peak_statistics.append( - StatisticData( - start=start, - last_reset=last_reset, - sum=sums["off_peak"], - state=states["off_peak"] - ) - ) - - peak_statistics.append( - StatisticData( - start=start, - last_reset=last_reset, - sum=sums["peak"], - state=states["peak"] - ) - ) - - return { - "total": total_statistics, - "peak": peak_statistics, - "off_peak": off_peak_statistics - } + return total_statistics async def async_get_last_sum(hass: HomeAssistant, latest_date: datetime, statistic_id: str) -> float: last_total_stat = await get_instance(hass).async_add_executor_job( diff --git a/custom_components/octopus_energy/statistics/consumption.py b/custom_components/octopus_energy/statistics/consumption.py index 0dbd8625b..3e2faca16 100644 --- a/custom_components/octopus_energy/statistics/consumption.py +++ b/custom_components/octopus_energy/statistics/consumption.py @@ -1,5 +1,6 @@ import logging import datetime + from . import (build_consumption_statistics, async_get_last_sum) from homeassistant.core import HomeAssistant @@ -9,6 +10,7 @@ ) from ..const import DOMAIN +from ..utils.rate_information import get_peak_name, get_peak_type, get_unique_rates, has_peak_rates _LOGGER = logging.getLogger(__name__) @@ -24,6 +26,14 @@ def get_gas_consumption_statistic_unique_id(serial_number: str, mpan: str, is_kw def get_gas_consumption_statistic_name(serial_number: str, mpan: str, is_kwh: bool = False): return f"Gas {serial_number} {mpan} Previous Accumulative Consumption{' (kWh)' if is_kwh else ''}" +class ImportConsumptionStatisticsResult: + total: float + peak_totals: "dict[str, float]" + + def __init__(self, total: float, peak_totals: "dict[str, float]"): + self.total = total + self.peak_totals = peak_totals + async def async_import_external_statistics_from_consumption( current: datetime, hass: HomeAssistant, @@ -33,7 +43,7 @@ async def async_import_external_statistics_from_consumption( rates, unit_of_measurement: str, consumption_key: str, - include_peak_off_peak: bool = True + initial_statistics: ImportConsumptionStatisticsResult = None ): if (consumptions is None or len(consumptions) < 1 or rates is None or len(rates) < 1): return @@ -41,15 +51,14 @@ async def async_import_external_statistics_from_consumption( statistic_id = f"{DOMAIN}:{unique_id}".lower() # Our sum needs to be based from the last total, so we need to grab the last record from the previous day - total_sum = await async_get_last_sum(hass, consumptions[0]["start"], statistic_id) + latest_total_sum = initial_statistics.total if initial_statistics is not None else await async_get_last_sum(hass, consumptions[0]["start"], statistic_id) - peak_statistic_id = f'{statistic_id}_peak' - peak_sum = await async_get_last_sum(hass, consumptions[0]["start"], peak_statistic_id) + unique_rates = get_unique_rates(current, rates) + total_unique_rates = len(unique_rates) - off_peak_statistic_id = f'{statistic_id}_off_peak' - off_peak_sum = await async_get_last_sum(hass, consumptions[0]["start"], off_peak_statistic_id) + _LOGGER.debug(f"statistic_id: {statistic_id}; latest_total_sum: {latest_total_sum}; total_unique_rates: {total_unique_rates};") - statistics = build_consumption_statistics(current, consumptions, rates, consumption_key, total_sum, peak_sum, off_peak_sum) + statistics = build_consumption_statistics(current, consumptions, rates, consumption_key, latest_total_sum) async_add_external_statistics( hass, @@ -61,32 +70,36 @@ async def async_import_external_statistics_from_consumption( statistic_id=statistic_id, unit_of_measurement=unit_of_measurement, ), - statistics["total"] + statistics ) - if include_peak_off_peak: - async_add_external_statistics( - hass, - StatisticMetaData( - has_mean=False, - has_sum=True, - name=f'{name} Peak', - source=DOMAIN, - statistic_id=peak_statistic_id, - unit_of_measurement=unit_of_measurement, - ), - statistics["peak"] - ) - - async_add_external_statistics( - hass, - StatisticMetaData( - has_mean=False, - has_sum=True, - name=f'{name} Off Peak', - source=DOMAIN, - statistic_id=off_peak_statistic_id, - unit_of_measurement=unit_of_measurement, - ), - statistics["off_peak"] - ) \ No newline at end of file + peak_totals = {} + if has_peak_rates(total_unique_rates): + for index in range(0, total_unique_rates): + peak_type = get_peak_type(total_unique_rates, index) + + _LOGGER.debug(f"Importing consumption statistics for '{peak_type}'...") + + target_rate = unique_rates[index] + peak_statistic_id = f'{statistic_id}_{peak_type}' + latest_peak_sum = initial_statistics.peak_totals[peak_type] if initial_statistics is not None and peak_type in initial_statistics.peak_totals else await async_get_last_sum(hass, consumptions[0]["start"], peak_statistic_id) + + peak_statistics = build_consumption_statistics(current, consumptions, rates, consumption_key, latest_peak_sum, target_rate) + + async_add_external_statistics( + hass, + StatisticMetaData( + has_mean=False, + has_sum=True, + name=f'{name} {get_peak_name(peak_type)}', + source=DOMAIN, + statistic_id=peak_statistic_id, + unit_of_measurement=unit_of_measurement, + ), + peak_statistics + ) + + peak_totals[peak_type] = peak_statistics[-1]["sum"] if peak_statistics[-1] is not None else 0 + + return ImportConsumptionStatisticsResult(statistics[-1]["sum"] if statistics[-1] is not None else 0, + peak_totals) \ No newline at end of file diff --git a/custom_components/octopus_energy/statistics/cost.py b/custom_components/octopus_energy/statistics/cost.py index a03382652..f27bdb764 100644 --- a/custom_components/octopus_energy/statistics/cost.py +++ b/custom_components/octopus_energy/statistics/cost.py @@ -1,6 +1,5 @@ import logging import datetime -from . import (build_cost_statistics, async_get_last_sum) from homeassistant.core import HomeAssistant from homeassistant.components.recorder.models import StatisticMetaData @@ -9,6 +8,8 @@ ) from ..const import DOMAIN +from ..utils.rate_information import get_peak_name, get_peak_type, get_unique_rates, has_peak_rates +from . import (build_cost_statistics, async_get_last_sum) _LOGGER = logging.getLogger(__name__) @@ -24,6 +25,14 @@ def get_gas_cost_statistic_unique_id(serial_number: str, mpan: str): def get_gas_cost_statistic_name(serial_number: str, mpan: str): return f"Gas {serial_number} {mpan} Previous Accumulative Cost" +class ImportCostStatisticsResult: + total: float + peak_totals: "dict[str, float]" + + def __init__(self, total: float, peak_totals: "dict[str, float]"): + self.total = total + self.peak_totals = peak_totals + async def async_import_external_statistics_from_cost( current: datetime, hass: HomeAssistant, @@ -33,7 +42,7 @@ async def async_import_external_statistics_from_cost( rates, unit_of_measurement: str, consumption_key: str, - include_peak_off_peak: bool = True + initial_statistics: ImportCostStatisticsResult = None ): if (consumptions is None or len(consumptions) < 1 or rates is None or len(rates) < 1): return @@ -41,15 +50,14 @@ async def async_import_external_statistics_from_cost( statistic_id = f"{DOMAIN}:{unique_id}".lower() # Our sum needs to be based from the last total, so we need to grab the last record from the previous day - total_sum = await async_get_last_sum(hass, consumptions[0]["start"], statistic_id) - - peak_statistic_id = f'{statistic_id}_peak' - peak_sum = await async_get_last_sum(hass, consumptions[0]["start"], peak_statistic_id) + latest_total_sum = initial_statistics.total if initial_statistics is not None else await async_get_last_sum(hass, consumptions[0]["start"], statistic_id) - off_peak_statistic_id = f'{statistic_id}_off_peak' - off_peak_sum = await async_get_last_sum(hass, consumptions[0]["start"], off_peak_statistic_id) + unique_rates = get_unique_rates(current, rates) + total_unique_rates = len(unique_rates) + + _LOGGER.debug(f"statistic_id: {statistic_id}; latest_total_sum: {latest_total_sum}; total_unique_rates: {total_unique_rates};") - statistics = build_cost_statistics(current, consumptions, rates, consumption_key, total_sum, peak_sum, off_peak_sum) + statistics = build_cost_statistics(current, consumptions, rates, consumption_key, latest_total_sum) async_add_external_statistics( hass, @@ -61,32 +69,36 @@ async def async_import_external_statistics_from_cost( statistic_id=statistic_id, unit_of_measurement=unit_of_measurement, ), - statistics["total"] + statistics ) - if (include_peak_off_peak): - async_add_external_statistics( - hass, - StatisticMetaData( - has_mean=False, - has_sum=True, - name=f'{name} Peak', - source=DOMAIN, - statistic_id=peak_statistic_id, - unit_of_measurement=unit_of_measurement, - ), - statistics["peak"] - ) - - async_add_external_statistics( - hass, - StatisticMetaData( - has_mean=False, - has_sum=True, - name=f'{name} Off Peak', - source=DOMAIN, - statistic_id=off_peak_statistic_id, - unit_of_measurement=unit_of_measurement, - ), - statistics["off_peak"] - ) \ No newline at end of file + peak_totals = {} + if has_peak_rates(total_unique_rates): + for index in range(0, total_unique_rates): + peak_type = get_peak_type(total_unique_rates, index) + + _LOGGER.debug(f"Importing cost statistics for '{peak_type}'...") + + target_rate = unique_rates[index] + peak_statistic_id = f'{statistic_id}_{peak_type}' + latest_peak_sum = initial_statistics.peak_totals[peak_type] if initial_statistics is not None and peak_type in initial_statistics.peak_totals else await async_get_last_sum(hass, consumptions[0]["start"], peak_statistic_id) + + peak_statistics = build_cost_statistics(current, consumptions, rates, consumption_key, latest_peak_sum, target_rate) + + async_add_external_statistics( + hass, + StatisticMetaData( + has_mean=False, + has_sum=True, + name=f'{name} {get_peak_name(peak_type)}', + source=DOMAIN, + statistic_id=peak_statistic_id, + unit_of_measurement=unit_of_measurement, + ), + peak_statistics + ) + + peak_totals[peak_type] = peak_statistics[-1]["sum"] if peak_statistics[-1] is not None else 0 + + return ImportCostStatisticsResult(statistics[-1]["sum"] if statistics[-1] is not None else 0, + peak_totals) \ No newline at end of file diff --git a/custom_components/octopus_energy/statistics/refresh.py b/custom_components/octopus_energy/statistics/refresh.py index 2af848cc8..71bacceef 100644 --- a/custom_components/octopus_energy/statistics/refresh.py +++ b/custom_components/octopus_energy/statistics/refresh.py @@ -12,19 +12,20 @@ from homeassistant.util.dt import (now, parse_datetime) from ..api_client import OctopusEnergyApiClient -from ..const import REGEX_DATE +from ..const import DATA_ACCOUNT, DOMAIN, REGEX_DATE from .consumption import async_import_external_statistics_from_consumption, get_electricity_consumption_statistic_name, get_electricity_consumption_statistic_unique_id, get_gas_consumption_statistic_name, get_gas_consumption_statistic_unique_id from .cost import async_import_external_statistics_from_cost, get_electricity_cost_statistic_name, get_electricity_cost_statistic_unique_id, get_gas_cost_statistic_name, get_gas_cost_statistic_unique_id from ..electricity import calculate_electricity_consumption_and_cost from ..gas import calculate_gas_consumption_and_cost +from ..coordinators import get_electricity_meter_tariff_code, get_gas_meter_tariff_code async def async_refresh_previous_electricity_consumption_data( hass: HomeAssistant, client: OctopusEnergyApiClient, + account_id: str, start_date: str, mpan: str, serial_number: str, - tariff_code: str, is_smart_meter: bool, is_export: bool ): @@ -34,6 +35,11 @@ async def async_refresh_previous_electricity_consumption_data( if matches is None: raise vol.Invalid(f"Date '{trimmed_date}' must match format of YYYY-MM-DD.") + account_result = hass.data[DOMAIN][account_id][DATA_ACCOUNT] if DATA_ACCOUNT in hass.data[DOMAIN][account_id] else None + account_info = account_result.account if account_result is not None else None + if account_info is None: + raise vol.Invalid(f"Failed to find account information") + persistent_notification.async_create( hass, title="Consumption data refreshing started", @@ -41,23 +47,33 @@ async def async_refresh_previous_electricity_consumption_data( ) period_from = parse_datetime(f'{trimmed_date}T00:00:00Z') + + previous_consumption_result = None + previous_cost_result= None while period_from < now(): period_to = period_from + timedelta(days=1) + tariff_code = get_electricity_meter_tariff_code(period_from, account_info, mpan, serial_number) + if tariff_code is None: + persistent_notification.async_create( + hass, + title="Failed to find tariff information", + message=f"Failed to find tariff information for {period_from}-{period_to} for electricity meter {serial_number}/{mpan}. Refreshing has stopped." + ) + return + consumption_data = await client.async_get_electricity_consumption(mpan, serial_number, period_from, period_to) rates = await client.async_get_electricity_rates(tariff_code, is_smart_meter, period_from, period_to) consumption_and_cost = calculate_electricity_consumption_and_cost( - period_from, consumption_data, rates, 0, - None, - tariff_code + None ) if consumption_and_cost is not None: - await async_import_external_statistics_from_consumption( + previous_consumption_result = await async_import_external_statistics_from_consumption( period_from, hass, get_electricity_consumption_statistic_unique_id(serial_number, mpan, is_export), @@ -65,10 +81,11 @@ async def async_refresh_previous_electricity_consumption_data( consumption_and_cost["charges"], rates, UnitOfEnergy.KILO_WATT_HOUR, - "consumption" + "consumption", + initial_statistics=previous_consumption_result ) - await async_import_external_statistics_from_cost( + previous_cost_result = await async_import_external_statistics_from_cost( period_from, hass, get_electricity_cost_statistic_unique_id(serial_number, mpan, is_export), @@ -76,7 +93,8 @@ async def async_refresh_previous_electricity_consumption_data( consumption_and_cost["charges"], rates, "GBP", - "consumption" + "consumption", + initial_statistics=previous_cost_result ) period_from = period_to @@ -90,10 +108,10 @@ async def async_refresh_previous_electricity_consumption_data( async def async_refresh_previous_gas_consumption_data( hass: HomeAssistant, client: OctopusEnergyApiClient, + account_id: str, start_date: str, mprn: str, serial_number: str, - tariff_code: str, consumption_units: str, calorific_value: float ): @@ -103,6 +121,11 @@ async def async_refresh_previous_gas_consumption_data( if matches is None: raise vol.Invalid(f"Date '{trimmed_date}' must match format of YYYY-MM-DD.") + account_result = hass.data[DOMAIN][account_id][DATA_ACCOUNT] if DATA_ACCOUNT in hass.data[DOMAIN][account_id] else None + account_info = account_result.account if account_result is not None else None + if account_info is None: + raise vol.Invalid(f"Failed to find account information") + persistent_notification.async_create( hass, title="Consumption data refreshing started", @@ -110,9 +133,21 @@ async def async_refresh_previous_gas_consumption_data( ) period_from = parse_datetime(f'{trimmed_date}T00:00:00Z') + previous_m3_consumption_result = None + previous_kwh_consumption_result = None + previous_cost_result = None while period_from < now(): period_to = period_from + timedelta(days=1) + tariff_code = get_gas_meter_tariff_code(period_from, account_info, mprn, serial_number) + if tariff_code is None: + persistent_notification.async_create( + hass, + title="Failed to find tariff information", + message=f"Failed to find tariff information for {period_from}-{period_to} for gas meter {serial_number}/{mprn}. Refreshing has stopped." + ) + return + consumption_data = await client.async_get_gas_consumption(mprn, serial_number, period_from, period_to) rates = await client.async_get_gas_rates(tariff_code, period_from, period_to) @@ -121,13 +156,12 @@ async def async_refresh_previous_gas_consumption_data( rates, 0, None, - tariff_code, consumption_units, calorific_value ) if consumption_and_cost is not None: - await async_import_external_statistics_from_consumption( + previous_m3_consumption_result = await async_import_external_statistics_from_consumption( period_from, hass, get_gas_consumption_statistic_unique_id(serial_number, mprn), @@ -136,10 +170,11 @@ async def async_refresh_previous_gas_consumption_data( rates, UnitOfVolume.CUBIC_METERS, "consumption_m3", - False + False, + initial_statistics=previous_m3_consumption_result ) - await async_import_external_statistics_from_consumption( + previous_kwh_consumption_result = await async_import_external_statistics_from_consumption( period_from, hass, get_gas_consumption_statistic_unique_id(serial_number, mprn, True), @@ -148,10 +183,11 @@ async def async_refresh_previous_gas_consumption_data( rates, UnitOfEnergy.KILO_WATT_HOUR, "consumption_kwh", - False + False, + initial_statistics=previous_kwh_consumption_result ) - await async_import_external_statistics_from_cost( + previous_cost_result = await async_import_external_statistics_from_cost( period_from, hass, get_gas_cost_statistic_unique_id(serial_number, mprn), @@ -160,7 +196,8 @@ async def async_refresh_previous_gas_consumption_data( rates, "GBP", "consumption_kwh", - False + False, + previous_cost_result ) period_from = period_to diff --git a/custom_components/octopus_energy/switch.py b/custom_components/octopus_energy/switch.py index d2f370d76..e07ef8e8a 100644 --- a/custom_components/octopus_energy/switch.py +++ b/custom_components/octopus_energy/switch.py @@ -4,6 +4,7 @@ from .intelligent.bump_charge import OctopusEnergyIntelligentBumpCharge from .api_client import OctopusEnergyApiClient from .intelligent import get_intelligent_features +from .api_client.intelligent_device import IntelligentDevice from .const import ( CONFIG_ACCOUNT_ID, @@ -44,9 +45,9 @@ async def async_setup_intelligent_sensors(hass, config, async_add_entities): account_id = account_info["id"] client = hass.data[DOMAIN][account_id][DATA_CLIENT] - intelligent_device = hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DEVICE] if DATA_INTELLIGENT_DEVICE in hass.data[DOMAIN][account_id] else None + intelligent_device: IntelligentDevice = hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DEVICE] if DATA_INTELLIGENT_DEVICE in hass.data[DOMAIN][account_id] else None if intelligent_device is not None: - intelligent_features = get_intelligent_features(intelligent_device["provider"]) + intelligent_features = get_intelligent_features(intelligent_device.provider) settings_coordinator = hass.data[DOMAIN][account_id][DATA_INTELLIGENT_SETTINGS_COORDINATOR] dispatches_coordinator = hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DISPATCHES_COORDINATOR] client: OctopusEnergyApiClient = hass.data[DOMAIN][account_id][DATA_CLIENT] diff --git a/custom_components/octopus_energy/target_rates/__init__.py b/custom_components/octopus_energy/target_rates/__init__.py index d38088218..e2178191a 100644 --- a/custom_components/octopus_energy/target_rates/__init__.py +++ b/custom_components/octopus_energy/target_rates/__init__.py @@ -6,7 +6,7 @@ from homeassistant.util.dt import (as_utc, parse_datetime) from ..utils.conversions import value_inc_vat_to_pounds -from ..const import REGEX_OFFSET_PARTS +from ..const import REGEX_OFFSET_PARTS, REGEX_WEIGHTING _LOGGER = logging.getLogger(__name__) @@ -85,7 +85,10 @@ def calculate_continuous_times( applicable_rates: list, target_hours: float, search_for_highest_rate = False, - find_last_rates = False + find_last_rates = False, + min_rate = None, + max_rate = None, + weighting: list = None ): if (applicable_rates is None): return [] @@ -94,6 +97,9 @@ def calculate_continuous_times( applicable_rates_count = len(applicable_rates) total_required_rates = math.ceil(target_hours * 2) + if weighting is not None and len(weighting) != total_required_rates: + raise ValueError("Weighting does not match target hours") + best_continuous_rates = None best_continuous_rates_total = None @@ -102,14 +108,27 @@ def calculate_continuous_times( # Loop through our rates and try and find the block of time that meets our desired # hours and has the lowest combined rates for index, rate in enumerate(applicable_rates): + if (min_rate is not None and rate["value_inc_vat"] < min_rate): + continue + + if (max_rate is not None and rate["value_inc_vat"] > max_rate): + continue + continuous_rates = [rate] - continuous_rates_total = rate["value_inc_vat"] + continuous_rates_total = rate["value_inc_vat"] * (weighting[0] if weighting is not None else 1) for offset in range(1, total_required_rates): if (index + offset) < applicable_rates_count: offset_rate = applicable_rates[(index + offset)] + + if (min_rate is not None and offset_rate["value_inc_vat"] < min_rate): + break + + if (max_rate is not None and offset_rate["value_inc_vat"] > max_rate): + break + continuous_rates.append(offset_rate) - continuous_rates_total += offset_rate["value_inc_vat"] + continuous_rates_total += offset_rate["value_inc_vat"] * (weighting[offset] if weighting is not None else 1) else: break @@ -130,7 +149,9 @@ def calculate_intermittent_times( applicable_rates: list, target_hours: float, search_for_highest_rate = False, - find_last_rates = False + find_last_rates = False, + min_rate = None, + max_rate = None ): if (applicable_rates is None): return [] @@ -148,6 +169,7 @@ def calculate_intermittent_times( else: applicable_rates.sort(key= lambda rate: (rate["value_inc_vat"], rate["end"])) + applicable_rates = list(filter(lambda rate: (min_rate is None or rate["value_inc_vat"] >= min_rate) and (max_rate is None or rate["value_inc_vat"] <= max_rate), applicable_rates)) applicable_rates = applicable_rates[:total_required_rates] _LOGGER.debug(f'{len(applicable_rates)} applicable rates found') @@ -271,4 +293,32 @@ def get_target_rate_info(current_date: datetime, applicable_rates, offset: str = "next_average_cost": next_average_cost, "next_min_cost": next_min_cost, "next_max_cost": next_max_cost, - } \ No newline at end of file + } + +def create_weighting(config: str, number_of_slots: int): + if config is None or config == "": + weighting = [] + for index in range(number_of_slots): + weighting.append(1) + + return weighting + + matches = re.search(REGEX_WEIGHTING, config) + if matches is None: + raise ValueError("Invalid config") + + parts = config.split(',') + parts_length = len(parts) + weighting = [] + for index in range(parts_length): + if (parts[index] == "*"): + # +1 to account for the current part + target_number_of_slots = number_of_slots - parts_length + 1 + for index in range(target_number_of_slots): + weighting.append(1) + + continue + + weighting.append(int(parts[index])) + + return weighting \ No newline at end of file diff --git a/custom_components/octopus_energy/target_rates/target_rate.py b/custom_components/octopus_energy/target_rates/target_rate.py index efb58ba27..4c0b59fc9 100644 --- a/custom_components/octopus_energy/target_rates/target_rate.py +++ b/custom_components/octopus_energy/target_rates/target_rate.py @@ -1,5 +1,6 @@ import logging from datetime import timedelta +import math import voluptuous as vol @@ -22,6 +23,8 @@ from homeassistant.helpers import translation from ..const import ( + CONFIG_TARGET_MAX_RATE, + CONFIG_TARGET_MIN_RATE, CONFIG_TARGET_NAME, CONFIG_TARGET_HOURS, CONFIG_TARGET_OLD_END_TIME, @@ -37,6 +40,9 @@ CONFIG_TARGET_LAST_RATES, CONFIG_TARGET_INVERT_TARGET_RATES, CONFIG_TARGET_OFFSET, + CONFIG_TARGET_TYPE_CONTINUOUS, + CONFIG_TARGET_TYPE_INTERMITTENT, + CONFIG_TARGET_WEIGHTING, DATA_ACCOUNT, DOMAIN, ) @@ -44,6 +50,7 @@ from . import ( calculate_continuous_times, calculate_intermittent_times, + create_weighting, get_applicable_rates, get_target_rate_info ) @@ -121,7 +128,8 @@ def _handle_coordinator_update(self) -> None: account_result = self._hass.data[DOMAIN][self._account_id][DATA_ACCOUNT] account_info = account_result.account if account_result is not None else None - check_for_errors(self._hass, self._config, account_info, now()) + current_local_date = now() + check_for_errors(self._hass, self._config, account_info, current_local_date) # Find the current rate. Rates change a maximum of once every 30 minutes. current_date = utcnow() @@ -170,29 +178,45 @@ def _handle_coordinator_update(self) -> None: if (CONFIG_TARGET_INVERT_TARGET_RATES in self._config): invert_target_rates = self._config[CONFIG_TARGET_INVERT_TARGET_RATES] + min_rate = None + if CONFIG_TARGET_MIN_RATE in self._config: + min_rate = self._config[CONFIG_TARGET_MIN_RATE] + + max_rate = None + if CONFIG_TARGET_MAX_RATE in self._config: + max_rate = self._config[CONFIG_TARGET_MAX_RATE] + find_highest_rates = (self._is_export and invert_target_rates == False) or (self._is_export == False and invert_target_rates) applicable_rates = get_applicable_rates( - current_date, + current_local_date, start_time, end_time, all_rates, is_rolling_target ) + + number_of_slots = math.ceil(target_hours * 2) + weighting = create_weighting(self._config[CONFIG_TARGET_WEIGHTING] if CONFIG_TARGET_WEIGHTING in self._config else None, number_of_slots) - if (self._config[CONFIG_TARGET_TYPE] == "Continuous"): + if (self._config[CONFIG_TARGET_TYPE] == CONFIG_TARGET_TYPE_CONTINUOUS): self._target_rates = calculate_continuous_times( applicable_rates, target_hours, find_highest_rates, - find_last_rates + find_last_rates, + min_rate, + max_rate, + weighting ) - elif (self._config[CONFIG_TARGET_TYPE] == "Intermittent"): + elif (self._config[CONFIG_TARGET_TYPE] == CONFIG_TARGET_TYPE_INTERMITTENT): self._target_rates = calculate_intermittent_times( applicable_rates, target_hours, find_highest_rates, - find_last_rates + find_last_rates, + min_rate, + max_rate ) else: _LOGGER.error(f"Unexpected target type: {self._config[CONFIG_TARGET_TYPE]}") @@ -223,6 +247,7 @@ def _handle_coordinator_update(self) -> None: self._state = active_result["is_active"] _LOGGER.debug(f"calculated: {self._state}") + self._attributes = dict_to_typed_dict(self._attributes) super()._handle_coordinator_update() async def async_added_to_hass(self): @@ -243,7 +268,7 @@ async def async_added_to_hass(self): _LOGGER.debug(f'Restored OctopusEnergyTargetRate state: {self._state}') @callback - async def async_update_config(self, target_start_time=None, target_end_time=None, target_hours=None, target_offset=None): + async def async_update_config(self, target_start_time=None, target_end_time=None, target_hours=None, target_offset=None, target_minimum_rate=None, target_maximum_rate=None, target_weighting=None): """Update sensors config""" config = dict(self._config) @@ -275,6 +300,27 @@ async def async_update_config(self, target_start_time=None, target_end_time=None CONFIG_TARGET_OFFSET: trimmed_target_offset }) + if target_minimum_rate is not None: + # Inputs from automations can include quotes, so remove these + trimmed_target_minimum_rate = target_minimum_rate.strip('\"') + config.update({ + CONFIG_TARGET_MIN_RATE: trimmed_target_minimum_rate if trimmed_target_minimum_rate != "" else None + }) + + if target_maximum_rate is not None: + # Inputs from automations can include quotes, so remove these + trimmed_target_maximum_rate = target_maximum_rate.strip('\"') + config.update({ + CONFIG_TARGET_MAX_RATE: trimmed_target_maximum_rate if trimmed_target_maximum_rate != "" else None + }) + + if target_weighting is not None: + # Inputs from automations can include quotes, so remove these + trimmed_target_weighting = target_weighting.strip('\"') + config.update({ + CONFIG_TARGET_WEIGHTING: trimmed_target_weighting if trimmed_target_weighting != "" else None + }) + account_result = self._hass.data[DOMAIN][self._account_id][DATA_ACCOUNT] account_info = account_result.account if account_result is not None else None diff --git a/custom_components/octopus_energy/time.py b/custom_components/octopus_energy/time.py index b8cfca8be..f55981eac 100644 --- a/custom_components/octopus_energy/time.py +++ b/custom_components/octopus_energy/time.py @@ -3,6 +3,7 @@ from .intelligent.ready_time import OctopusEnergyIntelligentReadyTime from .api_client import OctopusEnergyApiClient from .intelligent import get_intelligent_features +from .api_client.intelligent_device import IntelligentDevice from .const import ( CONFIG_ACCOUNT_ID, @@ -39,9 +40,9 @@ async def async_setup_intelligent_sensors(hass, config, async_add_entities): account_id = config[CONFIG_ACCOUNT_ID] client = hass.data[DOMAIN][account_id][DATA_CLIENT] - intelligent_device = hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DEVICE] if DATA_INTELLIGENT_DEVICE in hass.data[DOMAIN][account_id] else None + intelligent_device: IntelligentDevice = hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DEVICE] if DATA_INTELLIGENT_DEVICE in hass.data[DOMAIN][account_id] else None if intelligent_device is not None: - intelligent_features = get_intelligent_features(intelligent_device["provider"]) + intelligent_features = get_intelligent_features(intelligent_device.provider) settings_coordinator = hass.data[DOMAIN][account_id][DATA_INTELLIGENT_SETTINGS_COORDINATOR] client: OctopusEnergyApiClient = hass.data[DOMAIN][account_id][DATA_CLIENT] diff --git a/custom_components/octopus_energy/translations/en.json b/custom_components/octopus_energy/translations/en.json index 21d472f97..5f4ad7010 100644 --- a/custom_components/octopus_energy/translations/en.json +++ b/custom_components/octopus_energy/translations/en.json @@ -5,8 +5,8 @@ "account": { "description": "Setup your basic account information.", "data": { + "account_id": "Account ID (e.g. A-AAAA1111)", "api_key": "Api key", - "account_id": "Your account Id (e.g. A-AAAA1111)", "supports_live_consumption": "I have a Home Mini - https://octopus.energy/blog/octopus-home-mini/", "live_consumption_refresh_in_minutes": "Home Mini refresh rate in minutes", "live_electricity_consumption_refresh_in_minutes": "Home Mini electricity refresh rate in minutes", @@ -18,8 +18,8 @@ "gas_price_cap": "Optional gas price cap in pence" }, "data_description": { - "account_id": "This can be found on your bill or at the top of https://octopus.energy/dashboard", - "api_key": "This can be found at https://octopus.energy/dashboard/developer/", + "account_id": "You account ID can be found on your bill or at the top of https://octopus.energy/dashboard", + "api_key": "You API key can be found at https://octopus.energy/dashboard/new/accounts/personal-details/api-access", "calorific_value": "This can be found on your gas statement and can change from time to time" } }, @@ -35,7 +35,16 @@ "offset": "The offset to apply to the scheduled block to be considered active", "rolling_target": "Re-evaluate multiple times a day", "last_rates": "Find last applicable rates", - "target_invert_target_rates": "Invert targeted rates" + "target_invert_target_rates": "Invert targeted rates", + "minimum_rate": "The optional minimum rate for target hours", + "maximum_rate": "The optional maximum rate for target hours", + "weighting": "The optional weighting to apply to the discovered rates" + } + }, + "target_rate_account": { + "description": "Select the account your target rate sensor will be using for its calculations", + "data": { + "account_id": "Account" } }, "cost_tracker": { @@ -44,7 +53,15 @@ "name": "The name of your cost sensor", "mpan": "The meter the cost rates should be associated with", "target_entity_id": "The entity to track the costs for.", - "entity_accumulative_value": "Tracked entity state is accumulative" + "entity_accumulative_value": "Tracked entity state is accumulative", + "weekday_reset": "The day when the week cost sensor should reset", + "month_day_reset": "The day when the month cost sensor should reset" + } + }, + "cost_tracker_account": { + "description": "Select the account your cost tracker will be using for its calculations", + "data": { + "account_id": "Account" } } }, @@ -58,7 +75,14 @@ "invalid_offset": "Offset must be in the form of HH:MM:SS with an optional negative symbol", "invalid_hours_time_frame": "The target hours do not fit in the elected target time frame", "invalid_mpan": "Meter not found in account with an active tariff", - "invalid_end_time_agile": "Target time not fit for agile tariffs. Please consult target rate documentation for more information." + "invalid_end_time_agile": "Target time not fit for agile tariffs. Please consult target rate documentation for more information.", + "duplicate_account": "Account has already been configured", + "invalid_week_day": "Week reset day must be between 0 and 6 (inclusively)", + "invalid_month_day": "Month reset day must be between 1 and 28 (inclusively)", + "invalid_price": "Price must be in the form pounds and pence (e.g. 0.10)", + "invalid_weighting": "The weighting format is not supported.", + "invalid_weighting_slots": "The number of weighting blocks does not equal the specified number of hours.", + "weighting_not_supported": "Weighting is only supported for continuous target rates" }, "abort": { "not_supported": "Configuration for target rates is not supported at the moment.", @@ -82,7 +106,7 @@ "gas_price_cap": "Optional gas price cap in pence" }, "data_description": { - "api_key": "This can be found at https://octopus.energy/dashboard/developer/", + "api_key": "You API key can be found at https://octopus.energy/dashboard/new/accounts/personal-details/api-access", "calorific_value": "This can be found on your gas statement and can change from time to time" } }, @@ -97,7 +121,10 @@ "offset": "The offset to apply to the scheduled block to be considered active", "rolling_target": "Re-evaluate multiple times a day", "last_rates": "Find last applicable rates", - "target_invert_target_rates": "Invert targeted rates" + "target_invert_target_rates": "Invert targeted rates", + "minimum_rate": "The optional minimum rate for target hours", + "maximum_rate": "The optional maximum rate for target hours", + "weighting": "The optional weighting to apply to the discovered rates" } }, "cost_tracker": { @@ -106,7 +133,9 @@ "name": "The name of your cost sensor", "mpan": "The meter the cost rates should be associated with", "target_entity_id": "The entity to track the costs for.", - "entity_accumulative_value": "Tracked entity state is accumulative" + "entity_accumulative_value": "Tracked entity state is accumulative", + "weekday_reset": "The day when the week cost sensor should reset", + "month_day_reset": "The day when the month cost sensor should reset" } } }, @@ -119,33 +148,50 @@ "invalid_offset": "Offset must be in the form of HH:MM:SS with an optional negative symbol", "invalid_hours_time_frame": "The target hours do not fit in the elected target time frame", "invalid_mpan": "Meter not found in account with an active tariff", - "invalid_end_time_agile": "Target time not fit for agile tariffs. Please consult target rate documentation for more information." + "invalid_end_time_agile": "Target time not fit for agile tariffs. Please consult target rate documentation for more information.", + "invalid_week_day": "Week reset day must be between 0 and 6 (inclusively)", + "invalid_month_day": "Month reset day must be between 1 and 28 (inclusively)", + "invalid_price": "Price must be in the form pounds and pence (e.g. 0.10)", + "invalid_weighting": "The weighting format is not supported.", + "invalid_weighting_slots": "The number of weighting blocks does not equal the specified number of hours.", + "weighting_not_supported": "Weighting is only supported for continuous target rates" }, "abort": { "not_supported": "Configuration for target rates is not supported at the moment.", "account_not_found": "Account information is not found" } }, + "exceptions": { + "cost_tracker_invalid_date": { + "message": "Date must be between {min_date} and {max_date}" + }, + "octoplus_points_no_points": { + "message": "The minimum number of redeemable points is not available" + }, + "octoplus_points_maximum_points": { + "message": "You cannot redeem more than {redeemable_points} points" + } + }, "issues": { "account_not_found": { "title": "Account \"{account_id}\" not found", - "description": "The integration failed to retrieve the information associated with your configured account. Please check your account exists and that your API key is valid. Click 'Learn More' to find out how to fix this." + "description": "The integration failed to retrieve the information associated with your configured account. Please check your account exists and that your API key is valid. Click \"Learn More\" to find out how to fix this." }, "unknown_tariff_format": { "title": "Invalid format - {type} - {tariff_code}", - "description": "The tariff \"{tariff_code}\" associated with your {type} meter is not in an expected format. Click on 'Learn More' with instructions on what to do next." + "description": "The tariff \"{tariff_code}\" associated with your {type} meter is not in an expected format. Click on \"Learn More\" with instructions on what to do next." }, "unknown_tariff": { "title": "Unknown tariff - {type} - {tariff_code}", - "description": "The tariff \"{tariff_code}\" associated with your {type} meter has not been found. Click on 'Learn More' with instructions on what to do next." + "description": "The tariff \"{tariff_code}\" associated with your {type} meter has not been found. Click on \"Learn More\" with instructions on what to do next." }, "invalid_target_rate": { "title": "Invalid target rate \"{name}\"", - "description": "The target rate \"{name}\" has become invalid. Click on 'Learn More' with instructions on what to do next." + "description": "The target rate \"{name}\" has become invalid. Click on \"Learn More\" with instructions on what to do next." }, "octopus_mini_not_valid": { "title": "Octopus Mini not valid for {type} meter", - "description": "Octopus Mini has been configured for account '{account_id}', but it's not available for {type} meter {mpan_mprn}/{serial_number}. If this is expected, you can ignore this notice, otherwise click 'Learn More' with instructions on what to do next." + "description": "Octopus Mini has been configured for account \"{account_id}\", but it's not available for {type} meter {mpan_mprn}/{serial_number}. If this is expected, you can ignore this notice, otherwise click \"Learn More\" with instructions on what to do next." } } } \ No newline at end of file diff --git a/custom_components/octopus_energy/utils/__init__.py b/custom_components/octopus_energy/utils/__init__.py index fed449097..0bd8ae4f3 100644 --- a/custom_components/octopus_energy/utils/__init__.py +++ b/custom_components/octopus_energy/utils/__init__.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta -from homeassistant.util.dt import (as_utc, parse_datetime) +from homeassistant.util.dt import (as_local, as_utc, parse_datetime) from ..const import ( REGEX_TARIFF_PARTS, @@ -64,7 +64,8 @@ def get_active_tariff_code(utcnow: datetime, agreements): return None def get_off_peak_cost(current: datetime, rates: list): - today_start = as_utc(current.replace(hour=0, minute=0, second=0, microsecond=0)) + # Need to use as local to ensure we get the correct from/to periods relative to our local time + today_start = as_utc(as_local(current).replace(hour=0, minute=0, second=0, microsecond=0)) today_end = today_start + timedelta(days=1) off_peak_cost = None @@ -89,6 +90,40 @@ def is_off_peak(current: datetime, rates): rate_information["current_rate"]["is_intelligent_adjusted"] == False and value_inc_vat_to_pounds(off_peak_value) == rate_information["current_rate"]["value_inc_vat"]) +class OffPeakTime: + start: datetime + end: datetime + + def __init__(self, start, end): + self.start = start + self.end = end + +def get_off_peak_times(current: datetime, rates: list, include_intelligent_adjusted = False): + off_peak_value = get_off_peak_cost(current, rates) + times: list[OffPeakTime] = [] + + if rates is not None and off_peak_value is not None: + start = None + rates_length = len(rates) + for rate_index in range(rates_length): + rate = rates[rate_index] + if (rate["value_inc_vat"] == off_peak_value and + ("is_intelligent_adjusted" not in rate or rate["is_intelligent_adjusted"] == False or include_intelligent_adjusted)): + if start is None: + start = rate["start"] + elif start is not None: + end = rates[rate_index - 1]["end"] + if end >= current: + times.append(OffPeakTime(start, end)) + start = None + + if start is not None: + end = rates[-1]["end"] + if end >= current: + times.append(OffPeakTime(start, end)) + + return times + def private_rates_to_public_rates(rates: list): if rates is None: return None @@ -97,8 +132,8 @@ def private_rates_to_public_rates(rates: list): for rate in rates: new_rate = { - "start": rate["start"], - "end": rate["end"], + "start": as_local(rate["start"]), + "end": as_local(rate["end"]), "value_inc_vat": value_inc_vat_to_pounds(rate["value_inc_vat"]) } diff --git a/custom_components/octopus_energy/utils/attributes.py b/custom_components/octopus_energy/utils/attributes.py index 448330187..897ed0e80 100644 --- a/custom_components/octopus_energy/utils/attributes.py +++ b/custom_components/octopus_energy/utils/attributes.py @@ -1,6 +1,8 @@ import re from datetime import datetime +from homeassistant.util.dt import (as_local) + attribute_keys_to_skip = ['mpan', 'mprn'] def dict_to_typed_dict(data: dict, keys_to_ignore = []): @@ -34,7 +36,7 @@ def dict_to_typed_dict(data: dict, keys_to_ignore = []): is_date = True try: data_as_datetime = datetime.fromisoformat(new_data[key].replace('Z', '+00:00')) - new_data[key] = data_as_datetime + new_data[key] = as_local(data_as_datetime) continue except: # Do nothing @@ -48,6 +50,9 @@ def dict_to_typed_dict(data: dict, keys_to_ignore = []): new_array.append(dict_to_typed_dict(item)) new_data[key] = new_array + elif isinstance(new_data[key], datetime): + # Ensure all dates are in local time + new_data[key] = as_local(new_data[key]) return new_data diff --git a/custom_components/octopus_energy/utils/rate_information.py b/custom_components/octopus_energy/utils/rate_information.py index 2fe5651b4..6a71894b8 100644 --- a/custom_components/octopus_energy/utils/rate_information.py +++ b/custom_components/octopus_energy/utils/rate_information.py @@ -1,5 +1,7 @@ from datetime import (datetime, timedelta) +from homeassistant.util.dt import (as_local, as_utc) + from ..utils.conversions import value_inc_vat_to_pounds def get_current_rate_information(rates, now: datetime): @@ -57,6 +59,7 @@ def get_current_rate_information(rates, now: datetime): "current_rate": { "start": applicable_rates[0]["start"], "end": applicable_rates[-1]["end"], + "tariff_code": current_rate["tariff_code"], "value_inc_vat": value_inc_vat_to_pounds(applicable_rates[0]["value_inc_vat"]), "is_capped": current_rate["is_capped"], "is_intelligent_adjusted": current_rate["is_intelligent_adjusted"] if "is_intelligent_adjusted" in current_rate else False @@ -79,12 +82,15 @@ def get_previous_rate_information(rates, now: datetime): for period in reversed(rates): if now >= period["start"] and now <= period["end"]: current_rate = period + continue if current_rate is not None and current_rate["value_inc_vat"] != period["value_inc_vat"]: if len(applicable_rates) == 0 or period["value_inc_vat"] == applicable_rates[0]["value_inc_vat"]: applicable_rates.append(period) else: break + elif len(applicable_rates) > 0: + break applicable_rates.sort(key=get_from) @@ -114,12 +120,15 @@ def get_next_rate_information(rates, now: datetime): for period in rates: if now >= period["start"] and now <= period["end"]: current_rate = period + continue if current_rate is not None and current_rate["value_inc_vat"] != period["value_inc_vat"]: if len(applicable_rates) == 0 or period["value_inc_vat"] == applicable_rates[0]["value_inc_vat"]: applicable_rates.append(period) else: break + elif len(applicable_rates) > 0: + break if len(applicable_rates) > 0 and current_rate is not None: return { @@ -159,4 +168,66 @@ def get_min_max_average_rates(rates: list): "max": max_rate, # Round as there can be some minor inaccuracies with floats :( "average": round(average_rate / len(rates) if rates is not None and len(rates) > 0 else 1, 8) - } \ No newline at end of file + } + +def get_unique_rates(current: datetime, rates: list): + # Need to use as local to ensure we get the correct from/to periods relative to our local time + today_start = as_utc(as_local(current).replace(hour=0, minute=0, second=0, microsecond=0)) + today_end = today_start + timedelta(days=1) + + rate_charges = [] + if rates is not None: + for rate in rates: + if rate["start"] >= today_start and rate["end"] <= today_end: + value = rate["value_inc_vat"] + if value not in rate_charges: + rate_charges.append(value) + + rate_charges.sort() + + return rate_charges + +def has_peak_rates(total_unique_rates: int): + return total_unique_rates == 2 or total_unique_rates == 3 + +def get_peak_type(total_unique_rates: int, unique_rate_index: int): + if has_peak_rates(total_unique_rates) == False: + return None + + if unique_rate_index == 0: + return "off_peak" + elif unique_rate_index == 1: + if (total_unique_rates == 2): + return "peak" + else: + return "standard" + elif total_unique_rates > 2 and unique_rate_index == 2: + return "peak" + + return None + +def get_rate_index(total_unique_rates: int, peak_type: str | None): + if has_peak_rates(total_unique_rates) == False: + return None + + if peak_type == "off_peak": + return 0 + if peak_type == "standard": + return 1 + if peak_type == "peak": + if total_unique_rates == 2: + return 1 + else: + return 2 + + return None + +def get_peak_name(peak_type: str): + if (peak_type == "off_peak"): + return "Off Peak" + if (peak_type == "peak"): + return "Peak" + if (peak_type == "standard"): + return "Standard" + + return None \ No newline at end of file diff --git a/custom_components/octopus_energy/utils/requests.py b/custom_components/octopus_energy/utils/requests.py index c97ab7943..ab10f5449 100644 --- a/custom_components/octopus_energy/utils/requests.py +++ b/custom_components/octopus_energy/utils/requests.py @@ -10,6 +10,17 @@ def calculate_next_refresh(current: datetime, request_attempts: int, refresh_rat next_rate = current + timedelta(minutes=refresh_rate_in_minutes) if (request_attempts > 1): i = request_attempts - 1 + + # Cap at 30 minute intervals + number_of_additional_thirty_minutes = 0 + if i > 30: + number_of_additional_thirty_minutes = i - 30 + i = 30 + target_minutes = i * (i + 1) / 2 next_rate = next_rate + timedelta(minutes=target_minutes) + + if number_of_additional_thirty_minutes > 0: + next_rate = next_rate + timedelta(minutes=30*number_of_additional_thirty_minutes) + return next_rate \ No newline at end of file diff --git a/custom_components/octopus_energy/wheel_of_fortune/electricity_spins.py b/custom_components/octopus_energy/wheel_of_fortune/electricity_spins.py index 8cf03a664..266b083e2 100644 --- a/custom_components/octopus_energy/wheel_of_fortune/electricity_spins.py +++ b/custom_components/octopus_energy/wheel_of_fortune/electricity_spins.py @@ -46,7 +46,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Octopus Energy {self._account_id} Wheel Of Fortune Spins Electricity" + return f"Wheel Of Fortune Spins Electricity ({self._account_id})" @property def icon(self): @@ -75,6 +75,7 @@ def _handle_coordinator_update(self) -> None: self._attributes["data_last_retrieved"] = result.last_retrieved self._attributes["last_evaluated"] = utcnow() + self._attributes = dict_to_typed_dict(self._attributes) super()._handle_coordinator_update() async def async_added_to_hass(self): diff --git a/custom_components/octopus_energy/wheel_of_fortune/gas_spins.py b/custom_components/octopus_energy/wheel_of_fortune/gas_spins.py index e25142f6b..230042cbf 100644 --- a/custom_components/octopus_energy/wheel_of_fortune/gas_spins.py +++ b/custom_components/octopus_energy/wheel_of_fortune/gas_spins.py @@ -46,7 +46,7 @@ def unique_id(self): @property def name(self): """Name of the sensor.""" - return f"Octopus Energy {self._account_id} Wheel Of Fortune Spins Gas" + return f"Wheel Of Fortune Spins Gas ({self._account_id})" @property def icon(self): @@ -75,6 +75,7 @@ def _handle_coordinator_update(self) -> None: self._attributes["data_last_retrieved"] = result.last_retrieved self._attributes["last_evaluated"] = utcnow() + self._attributes = dict_to_typed_dict(self._attributes) super()._handle_coordinator_update() async def async_added_to_hass(self): From 733aa739e66969b8553e389ae1213ee5cda91c26 Mon Sep 17 00:00:00 2001 From: Kyle Gordon Date: Mon, 17 Jun 2024 13:30:46 +1000 Subject: [PATCH 2/4] Continue on error with dev builds --- .github/workflows/esphome-parallel.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/esphome-parallel.yaml b/.github/workflows/esphome-parallel.yaml index 054edcf9e..aaf131c7b 100644 --- a/.github/workflows/esphome-parallel.yaml +++ b/.github/workflows/esphome-parallel.yaml @@ -100,6 +100,7 @@ jobs: cp -R esphome/travis_secrets.yaml.txt esphome/secrets.yaml - name: Compile all ESPHome ${{matrix.file}} uses: esphome/build-action@v2 + continue-on-error: true with: version: dev yaml_file: ${{matrix.file}} From 6b9e1a572a1b6bf74506b7da1727f8f1b5580e26 Mon Sep 17 00:00:00 2001 From: Kyle Gordon Date: Mon, 17 Jun 2024 13:30:58 +1000 Subject: [PATCH 3/4] Reinstate original OTA without platform --- esphome/common/common.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/common/common.yaml b/esphome/common/common.yaml index f85af7eec..6dc610ef3 100644 --- a/esphome/common/common.yaml +++ b/esphome/common/common.yaml @@ -1,7 +1,6 @@ api: ota: - platform: esphome wifi: ssid: !secret wifi_ssid From ed28302ebd78ce8b216e6637de65073dd87a2858 Mon Sep 17 00:00:00 2001 From: Kyle Gordon Date: Mon, 17 Jun 2024 17:19:43 +1000 Subject: [PATCH 4/4] Doesn't need loop-dev --- .github/workflows/esphome-parallel.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/esphome-parallel.yaml b/.github/workflows/esphome-parallel.yaml index aaf131c7b..e882ef8a0 100644 --- a/.github/workflows/esphome-parallel.yaml +++ b/.github/workflows/esphome-parallel.yaml @@ -109,7 +109,7 @@ jobs: final: name: Final ESPHome check runs-on: ubuntu-latest - needs: [loop-stable, loop-beta, loop-dev] + needs: [loop-stable, loop-beta] steps: - name: Getting your configuration from GitHub uses: actions/checkout@v4