From fb0f8e3b0cd4c050cd5a99abc25dc0169172535e Mon Sep 17 00:00:00 2001 From: BottlecapDave Date: Fri, 20 Dec 2024 07:48:02 +0000 Subject: [PATCH 01/18] feat: Added sensor to show the current raw intelligent state (1 hour dev time) --- .../octopus_energy/api_client/__init__.py | 17 ++- .../api_client/intelligent_dispatches.py | 3 + .../coordinators/intelligent_dispatches.py | 2 +- .../octopus_energy/intelligent/__init__.py | 19 ++- .../intelligent/current_state.py | 111 ++++++++++++++++++ custom_components/octopus_energy/sensor.py | 8 +- .../test_get_intelligent_dispatches.py | 4 +- ...st_async_refresh_electricity_rates_data.py | 34 +++--- ...st_async_refresh_intelligent_dispatches.py | 16 +-- .../test_previous_consumption_and_rates.py | 1 + .../test_get_intelligent_features.py | 82 ++++++------- 11 files changed, 224 insertions(+), 73 deletions(-) create mode 100644 custom_components/octopus_energy/intelligent/current_state.py diff --git a/custom_components/octopus_energy/api_client/__init__.py b/custom_components/octopus_energy/api_client/__init__.py index e7009d44..3c01af56 100644 --- a/custom_components/octopus_energy/api_client/__init__.py +++ b/custom_components/octopus_energy/api_client/__init__.py @@ -115,6 +115,12 @@ }}''' intelligent_dispatches_query = '''query {{ + devices(accountNumber: "{account_id}", deviceId: "{device_id}") {{ + id + status {{ + currentState + }} + }} plannedDispatches(accountNumber: "{account_id}") {{ start end @@ -1258,7 +1264,7 @@ async def async_get_gas_standing_charge(self, product_code: str, tariff_code: st _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') raise TimeoutException() - async def async_get_intelligent_dispatches(self, account_id: str): + async def async_get_intelligent_dispatches(self, account_id: str, device_id: str): """Get the user's intelligent dispatches""" await self.async_refresh_token() @@ -1266,14 +1272,21 @@ async def async_get_intelligent_dispatches(self, account_id: str): 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) } + payload = { "query": intelligent_dispatches_query.format(account_id=account_id, device_id=device_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}') + current_state = None + if (response_body is not None and "data" in response_body and "devices" in response_body["data"]): + for device in response_body["data"]["devices"]: + if device["id"] == device_id: + current_state = device["status"]["currentState"] + if (response_body is not None and "data" in response_body): return IntelligentDispatches( + current_state, list(map(lambda ev: IntelligentDispatchItem( as_utc(parse_datetime(ev["start"])), as_utc(parse_datetime(ev["end"])), diff --git a/custom_components/octopus_energy/api_client/intelligent_dispatches.py b/custom_components/octopus_energy/api_client/intelligent_dispatches.py index 36fd20df..8effa98f 100644 --- a/custom_components/octopus_energy/api_client/intelligent_dispatches.py +++ b/custom_components/octopus_energy/api_client/intelligent_dispatches.py @@ -22,13 +22,16 @@ def __init__( self.location = location class IntelligentDispatches: + current_state: str | None planned: list[IntelligentDispatchItem] completed: list[IntelligentDispatchItem] def __init__( self, + current_state: str | None, planned: list[IntelligentDispatchItem], completed: list[IntelligentDispatchItem] ): + self.current_state = current_state self.planned = planned self.completed = completed \ No newline at end of file diff --git a/custom_components/octopus_energy/coordinators/intelligent_dispatches.py b/custom_components/octopus_energy/coordinators/intelligent_dispatches.py index 4017f535..e4084a19 100644 --- a/custom_components/octopus_energy/coordinators/intelligent_dispatches.py +++ b/custom_components/octopus_energy/coordinators/intelligent_dispatches.py @@ -72,7 +72,7 @@ async def async_refresh_intelligent_dispatches( raised_exception = None if has_intelligent_tariff(current, account_info) and intelligent_device is not None: try: - dispatches = await client.async_get_intelligent_dispatches(account_id) + dispatches = await client.async_get_intelligent_dispatches(account_id, intelligent_device.id) _LOGGER.debug(f'Intelligent dispatches retrieved for account {account_id}') except Exception as e: if isinstance(e, ApiException) == False: diff --git a/custom_components/octopus_energy/intelligent/__init__.py b/custom_components/octopus_energy/intelligent/__init__.py index ef0a753e..523f6d35 100644 --- a/custom_components/octopus_energy/intelligent/__init__.py +++ b/custom_components/octopus_energy/intelligent/__init__.py @@ -21,6 +21,7 @@ def mock_intelligent_dispatches() -> IntelligentDispatches: planned: list[IntelligentDispatchItem] = [] completed: list[IntelligentDispatchItem] = [] + current_state = "SMART_CONTROL_CAPABLE" dispatches = [ IntelligentDispatchItem( @@ -71,12 +72,18 @@ def mock_intelligent_dispatches() -> IntelligentDispatches: ) for dispatch in dispatches: + if utcnow() >= dispatch.start and utcnow() <= dispatch.end: + if dispatch.source == INTELLIGENT_SOURCE_SMART_CHARGE: + current_state = "SMART_CONTROL_IN_PROGRESS" + elif dispatch.source == INTELLIGENT_SOURCE_BUMP_CHARGE: + current_state = "BOOSTING" + if (dispatch.end > utcnow()): planned.append(dispatch) else: completed.append(dispatch) - return IntelligentDispatches(planned, completed) + return IntelligentDispatches(current_state, planned, completed) def mock_intelligent_settings(): return IntelligentSettings( @@ -206,13 +213,15 @@ def __init__(self, charge_limit_supported: bool, planned_dispatches_supported: bool, ready_time_supported: bool, - smart_charge_supported: bool): + smart_charge_supported: bool, + current_state_supported: bool): self.is_default_features = is_default_features self.bump_charge_supported = bump_charge_supported self.charge_limit_supported = charge_limit_supported self.planned_dispatches_supported = planned_dispatches_supported self.ready_time_supported = ready_time_supported self.smart_charge_supported = smart_charge_supported + self.current_state_supported = current_state_supported FULLY_SUPPORTED_INTELLIGENT_PROVIDERS = [ "DAIKIN", @@ -236,8 +245,8 @@ def __init__(self, def get_intelligent_features(provider: str) -> IntelligentFeatures: normalised_provider = provider.upper() if provider is not None else None if normalised_provider is not None and normalised_provider in FULLY_SUPPORTED_INTELLIGENT_PROVIDERS: - return IntelligentFeatures(False, True, True, True, True, True) + return IntelligentFeatures(False, True, True, True, True, True, True) elif normalised_provider == "OHME": - return IntelligentFeatures(False, False, False, False, False, False) + return IntelligentFeatures(False, False, False, False, False, False, False) - return IntelligentFeatures(True, False, False, False, False, False) \ No newline at end of file + return IntelligentFeatures(True, False, False, False, False, False, False) \ No newline at end of file diff --git a/custom_components/octopus_energy/intelligent/current_state.py b/custom_components/octopus_energy/intelligent/current_state.py new file mode 100644 index 00000000..d15e7c31 --- /dev/null +++ b/custom_components/octopus_energy/intelligent/current_state.py @@ -0,0 +1,111 @@ +import logging + +from homeassistant.const import ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import generate_entity_id + +from homeassistant.util.dt import (now) +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity +) +from homeassistant.components.sensor import ( + RestoreSensor, +) + +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 + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyIntelligentCurrentState(CoordinatorEntity, OctopusEnergyIntelligentSensor, RestoreSensor): + """Sensor for determining the current intelligent state.""" + + _unrecorded_attributes = frozenset({"known_states"}) + + known_states = [ + "AUTHENTICATION_PENDING", + "AUTHENTICATION_FAILED", + "AUTHENTICATION_COMPLETE", + "TEST_CHARGE_IN_PROGRESS", + "TEST_CHARGE_FAILED", + "TEST_CHARGE_NOT_AVAILABLE", + "SETUP_COMPLETE", + "SMART_CONTROL_CAPABLE", + "SMART_CONTROL_IN_PROGRESS", + "BOOSTING", + "SMART_CONTROL_OFF", + "SMART_CONTROL_NOT_AVAILABLE", + "LOST_CONNECTION", + "RETIRED", + ] + + def __init__(self, hass: HomeAssistant, coordinator, device: IntelligentDevice, account_id: str): + """Init sensor.""" + + CoordinatorEntity.__init__(self, coordinator) + OctopusEnergyIntelligentSensor.__init__(self, device) + + self._account_id = account_id + self._state = None + self._attributes = { + "known_states": self.known_states + } + + self.entity_id = generate_entity_id("sensor.{}", self.unique_id, hass=hass) + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_{self._account_id}_intelligent_state" + + @property + def name(self): + """Name of the sensor.""" + return f"Intelligent State ({self._account_id})" + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:power-plug-battery" + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def native_value(self): + return self._state + + @callback + 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 = now() + result: IntelligentDispatchesCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None + if (result is not None and result.dispatches is not None): + _LOGGER.debug(f"Updating OctopusEnergyIntelligentCurrentState for '{self._device.id}'") + + self._state = result.dispatches.current_state + else: + self._state = None + + self._attributes = dict_to_typed_dict(self._attributes) + super()._handle_coordinator_update() + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None and self._state is None: + self._state = None if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) else state.state + self._attributes = dict_to_typed_dict(state.attributes, []) + self._attributes["known_states"] = self.known_states + _LOGGER.debug(f'Restored OctopusEnergyIntelligentCurrentState state: {self._state}') diff --git a/custom_components/octopus_energy/sensor.py b/custom_components/octopus_energy/sensor.py index 344465ba..aeea20fe 100644 --- a/custom_components/octopus_energy/sensor.py +++ b/custom_components/octopus_energy/sensor.py @@ -1,5 +1,4 @@ from datetime import timedelta -from custom_components.octopus_energy.api_client.intelligent_device import IntelligentDevice import voluptuous as vol import logging @@ -57,6 +56,9 @@ from .diagnostics_entities.intelligent_dispatches_data_last_retrieved import OctopusEnergyIntelligentDispatchesDataLastRetrieved from .diagnostics_entities.intelligent_settings_data_last_retrieved import OctopusEnergyIntelligentSettingsDataLastRetrieved from .diagnostics_entities.free_electricity_sessions_data_last_retrieved import OctopusEnergyFreeElectricitySessionsDataLastRetrieved +from .api_client.intelligent_device import IntelligentDevice +from .intelligent.current_state import OctopusEnergyIntelligentCurrentState +from .intelligent import get_intelligent_features from .utils.debug_overrides import async_get_debug_override @@ -280,6 +282,10 @@ async def async_setup_default_sensors(hass: HomeAssistant, config, async_add_ent intelligent_dispatches_coordinator = hass.data[DOMAIN][account_id][DATA_INTELLIGENT_DISPATCHES_COORDINATOR] if DATA_INTELLIGENT_DISPATCHES_COORDINATOR in hass.data[DOMAIN][account_id] else None if intelligent_dispatches_coordinator is not None: entities.append(OctopusEnergyIntelligentDispatchesDataLastRetrieved(hass, intelligent_dispatches_coordinator, account_id)) + + intelligent_features = get_intelligent_features(intelligent_device.provider) + if intelligent_features.current_state_supported: + entities.append(OctopusEnergyIntelligentCurrentState(hass, intelligent_dispatches_coordinator, intelligent_device, account_id)) intelligent_settings_coordinator = hass.data[DOMAIN][account_id][DATA_INTELLIGENT_SETTINGS_COORDINATOR] if DATA_INTELLIGENT_SETTINGS_COORDINATOR in hass.data[DOMAIN][account_id] else None if intelligent_settings_coordinator is not None: diff --git a/tests/integration/api_client/test_get_intelligent_dispatches.py b/tests/integration/api_client/test_get_intelligent_dispatches.py index da730fef..8cb566c2 100644 --- a/tests/integration/api_client/test_get_intelligent_dispatches.py +++ b/tests/integration/api_client/test_get_intelligent_dispatches.py @@ -4,7 +4,7 @@ from custom_components.octopus_energy.api_client import OctopusEnergyApiClient @pytest.mark.asyncio -async def test_when_get_intelligent_dispatches_is_called_for_account_on_different_tariff_then_results_are_returned(): +async def test_when_get_intelligent_dispatches_is_called_for_account_on_different_tariff_then_exception_is_raised(): # Arrange context = get_test_context() @@ -14,7 +14,7 @@ async def test_when_get_intelligent_dispatches_is_called_for_account_on_differen # Act exception_raised = False try: - await client.async_get_intelligent_dispatches(account_id) + await client.async_get_intelligent_dispatches(account_id, "123") except: exception_raised = True diff --git a/tests/unit/coordinators/test_async_refresh_electricity_rates_data.py b/tests/unit/coordinators/test_async_refresh_electricity_rates_data.py index e477f18b..110132d1 100644 --- a/tests/unit/coordinators/test_async_refresh_electricity_rates_data.py +++ b/tests/unit/coordinators/test_async_refresh_electricity_rates_data.py @@ -103,7 +103,7 @@ def fire_event(name, metadata): account_info = None existing_rates = ElectricityRatesCoordinatorResult(period_from, 1, create_rate_data(period_from, period_to, [2, 4])) - dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches([], [])) + dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") @@ -143,7 +143,7 @@ def fire_event(name, metadata): account_info = get_account_info(False) existing_rates = ElectricityRatesCoordinatorResult(period_from, 1, create_rate_data(period_from, period_to, [2, 4])) - dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches([], [])) + dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") @@ -184,7 +184,7 @@ def fire_event(name, metadata): account_info = get_account_info() existing_rates = ElectricityRatesCoordinatorResult(current - timedelta(minutes=4, seconds=59), 1, create_rate_data(period_from, period_to, [2, 4])) - dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches([], [])) + dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") @@ -243,7 +243,7 @@ def fire_event(name, metadata): account_info = get_account_info() expected_retrieved_rates = ElectricityRatesCoordinatorResult(current, 1, expected_rates_unsorted) - dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches([], [])) + dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") @@ -280,8 +280,8 @@ def fire_event(name, metadata): @pytest.mark.parametrize("dispatches_result",[ (None), (IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, None)), - (IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches(None, []))), - (IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches([], None))), + (IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", None, []))), + (IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], None))), ]) async def test_when_dispatches_is_not_defined_and_existing_rates_is_none_then_rates_retrieved(dispatches_result): expected_period_from = (current - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) @@ -366,7 +366,7 @@ def fire_event(name, metadata): account_info = get_account_info() existing_rates = ElectricityRatesCoordinatorResult(period_to - timedelta(days=60), 1, create_rate_data(period_from - timedelta(days=60), period_to - timedelta(days=60), [2, 4])) expected_retrieved_rates = ElectricityRatesCoordinatorResult(current, 1, expected_rates) - dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches([], [])) + dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") @@ -417,7 +417,7 @@ def fire_event(name, metadata): account_info = get_account_info() existing_rates = ElectricityRatesCoordinatorResult(period_to - timedelta(days=1), 1, create_rate_data(expected_period_from, expected_period_to, [2, 4], default_tariff_code)) expected_retrieved_rates = ElectricityRatesCoordinatorResult(current, 1, existing_rates.rates) - dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches([], [])) + dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") @@ -474,7 +474,7 @@ def fire_event(name, metadata): account_info = get_account_info() existing_rates = ElectricityRatesCoordinatorResult(period_to - timedelta(days=1), 1, create_rate_data(expected_period_from, expected_period_to, [2, 4], f"{default_tariff_code}-diff")) expected_retrieved_rates = ElectricityRatesCoordinatorResult(current, 1, existing_rates.rates) - dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches([], [])) + dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") @@ -534,7 +534,7 @@ def fire_event(name, metadata): account_info = get_account_info() existing_rates = ElectricityRatesCoordinatorResult(period_to - timedelta(days=60), 1, create_rate_data(expected_period_from, expected_rates[0]["start"], [1], default_tariff_code)) expected_retrieved_rates = ElectricityRatesCoordinatorResult(current, 1, expected_rates) - dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches([], [])) + dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") @@ -614,7 +614,7 @@ def fire_event(name, metadata): account_info = get_account_info() existing_rates = ElectricityRatesCoordinatorResult(period_to - timedelta(days=60), 1, create_rate_data(expected_period_from, expected_rates[0]["start"], [1], f"{default_tariff_code}-new")) expected_retrieved_rates = ElectricityRatesCoordinatorResult(current, 1, expected_rates) - dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches([], [])) + dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") @@ -677,6 +677,7 @@ def fire_event(name, metadata): expected_dispatch_start = (current + timedelta(hours=2)).replace(second=0, microsecond=0) expected_dispatch_end = expected_dispatch_start + timedelta(minutes=90) dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches( + "SMART_CONTROL_IN_PROGRESS", [ IntelligentDispatchItem( expected_dispatch_start, @@ -764,6 +765,7 @@ def fire_event(name, metadata): expected_dispatch_start = (current + timedelta(hours=2)).replace(second=0, microsecond=0) expected_dispatch_end = expected_dispatch_start + timedelta(minutes=90) dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches( + "SMART_CONTROL_IN_PROGRESS", [ IntelligentDispatchItem( expected_dispatch_start, @@ -839,7 +841,7 @@ def fire_event(name, metadata): account_info = get_account_info() existing_rates = ElectricityRatesCoordinatorResult(period_from, 1, create_rate_data(period_from, period_to, [1, 2, 3, 4])) - dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches([], [])) + dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") @@ -886,7 +888,7 @@ def fire_event(name, metadata): account_info = get_account_info() existing_rates = ElectricityRatesCoordinatorResult(period_from, 1, create_rate_data(period_from, period_to, [1, 2, 3, 4])) - dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches([], [])) + dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") @@ -939,6 +941,7 @@ def fire_event(name, metadata): expected_dispatch_start = (current + timedelta(hours=2)).replace(second=0, microsecond=0) expected_dispatch_end = expected_dispatch_start + timedelta(minutes=90) dispatches_result = IntelligentDispatchesCoordinatorResult(existing_rates.last_evaluated + timedelta(seconds=1), 1, IntelligentDispatches( + "SMART_CONTROL_IN_PROGRESS", [ IntelligentDispatchItem( expected_dispatch_start, @@ -1024,6 +1027,7 @@ def fire_event(name, metadata): expected_dispatch_start = (current + timedelta(hours=2)).replace(second=0, microsecond=0) expected_dispatch_end = expected_dispatch_start + timedelta(minutes=90) dispatches_result = IntelligentDispatchesCoordinatorResult(existing_rates.last_evaluated + timedelta(seconds=1), 1, IntelligentDispatches( + "SMART_CONTROL_IN_PROGRESS", [ IntelligentDispatchItem( expected_dispatch_start, @@ -1078,6 +1082,7 @@ def fire_event(name, metadata): expected_dispatch_start = (current + timedelta(hours=2)).replace(second=0, microsecond=0) expected_dispatch_end = expected_dispatch_start + timedelta(minutes=90) dispatches_result = IntelligentDispatchesCoordinatorResult(existing_rates.last_evaluated - timedelta(seconds=1), 1, IntelligentDispatches( + "SMART_CONTROL_IN_PROGRESS", [ IntelligentDispatchItem( expected_dispatch_start, @@ -1184,6 +1189,7 @@ def fire_event(name, metadata): expected_dispatch_start = (current + timedelta(hours=2)).replace(second=0, microsecond=0) expected_dispatch_end = expected_dispatch_start + timedelta(minutes=90) dispatches_result = IntelligentDispatchesCoordinatorResult(existing_rates.last_evaluated + timedelta(seconds=1), 1, IntelligentDispatches( + "SMART_CONTROL_IN_PROGRESS", [ IntelligentDispatchItem( expected_dispatch_start, @@ -1361,7 +1367,7 @@ def fire_event(name, metadata): account_info = get_account_info() expected_retrieved_rates = ElectricityRatesCoordinatorResult(current, 1, expected_rates_unsorted) - dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches([], [])) + dispatches_result = IntelligentDispatchesCoordinatorResult(dispatches_last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) with mock.patch.multiple(OctopusEnergyApiClient, async_get_electricity_rates=async_mocked_get_electricity_rates): client = OctopusEnergyApiClient("NOT_REAL") diff --git a/tests/unit/coordinators/test_async_refresh_intelligent_dispatches.py b/tests/unit/coordinators/test_async_refresh_intelligent_dispatches.py index 0c030102..3e7dec56 100644 --- a/tests/unit/coordinators/test_async_refresh_intelligent_dispatches.py +++ b/tests/unit/coordinators/test_async_refresh_intelligent_dispatches.py @@ -50,7 +50,7 @@ def get_account_info(is_active_agreement = True, active_product_code = product_c @pytest.mark.asyncio async def test_when_account_info_is_none_then_existing_settings_returned(): - expected_dispatches = IntelligentDispatches([], []) + expected_dispatches = IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], []) mock_api_called = False async def async_mock_get_intelligent_dispatches(*args, **kwargs): nonlocal mock_api_called @@ -81,7 +81,7 @@ async def async_merge_dispatch_data(*args, **kwargs): @pytest.mark.asyncio async def test_when_intelligent_device_is_none_then_none_returned(): - expected_dispatches = IntelligentDispatches([], []) + expected_dispatches = IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], []) mock_api_called = False async def async_mock_get_intelligent_dispatches(*args, **kwargs): nonlocal mock_api_called @@ -113,7 +113,7 @@ async def async_merge_dispatch_data(*args, **kwargs): @pytest.mark.asyncio async def test_when_not_on_intelligent_tariff_then_none_returned(): - expected_dispatches = IntelligentDispatches([], []) + expected_dispatches = IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], []) mock_api_called = False async def async_mock_get_intelligent_dispatches(*args, **kwargs): nonlocal mock_api_called @@ -194,7 +194,7 @@ async def async_merge_dispatch_data(*args, **kwargs): @pytest.mark.asyncio async def test_when_next_refresh_is_in_the_future_then_existing_dispatches_returned(): current = datetime.strptime("2023-07-14T10:30:01+01:00", "%Y-%m-%dT%H:%M:%S%z") - expected_dispatches = IntelligentDispatches([], []) + expected_dispatches = IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], []) mock_api_called = False async def async_mock_get_intelligent_dispatches(*args, **kwargs): nonlocal mock_api_called @@ -230,7 +230,7 @@ async def async_merge_dispatch_data(*args, **kwargs): (IntelligentDispatchesCoordinatorResult(last_retrieved, 1, None)), ]) async def test_when_existing_settings_is_none_then_settings_retrieved(existing_settings): - expected_dispatches = IntelligentDispatches([], []) + expected_dispatches = IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], []) mock_api_called = False async def async_mock_get_intelligent_dispatches(*args, **kwargs): nonlocal mock_api_called, expected_dispatches @@ -263,7 +263,7 @@ async def async_merge_dispatch_data(*args, **kwargs): @pytest.mark.asyncio async def test_when_existing_settings_is_old_then_settings_retrieved(): - expected_dispatches = IntelligentDispatches([], []) + expected_dispatches = IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], []) mock_api_called = False async def async_mock_get_intelligent_dispatches(*args, **kwargs): nonlocal mock_api_called @@ -309,7 +309,7 @@ async def async_merge_dispatch_data(*args, **kwargs): return completed_dispatches account_info = get_account_info() - existing_settings = IntelligentDispatchesCoordinatorResult(last_retrieved, 1, IntelligentDispatches([], [])) + existing_settings = IntelligentDispatchesCoordinatorResult(last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) with mock.patch.multiple(OctopusEnergyApiClient, async_get_intelligent_dispatches=async_mock_get_intelligent_dispatches): client = OctopusEnergyApiClient("NOT_REAL") @@ -345,7 +345,7 @@ async def async_merge_dispatch_data(*args, **kwargs): return completed_dispatches account_info = get_account_info() - existing_settings = IntelligentDispatchesCoordinatorResult(last_retrieved, 1, IntelligentDispatches([], [])) + existing_settings = IntelligentDispatchesCoordinatorResult(last_retrieved, 1, IntelligentDispatches("SMART_CONTROL_IN_PROGRESS", [], [])) with mock.patch.multiple(OctopusEnergyApiClient, async_get_intelligent_dispatches=async_mock_get_intelligent_dispatches): client = OctopusEnergyApiClient("NOT_REAL") diff --git a/tests/unit/coordinators/test_previous_consumption_and_rates.py b/tests/unit/coordinators/test_previous_consumption_and_rates.py index 1e2259c2..79c2df56 100644 --- a/tests/unit/coordinators/test_previous_consumption_and_rates.py +++ b/tests/unit/coordinators/test_previous_consumption_and_rates.py @@ -889,6 +889,7 @@ def fire_event(name, metadata): ) intelligent_dispatches = IntelligentDispatches( + "SMART_CONTROL_IN_PROGRESS", [], [ IntelligentDispatchItem( diff --git a/tests/unit/intelligent/test_get_intelligent_features.py b/tests/unit/intelligent/test_get_intelligent_features.py index d8910e3f..03fbd858 100644 --- a/tests/unit/intelligent/test_get_intelligent_features.py +++ b/tests/unit/intelligent/test_get_intelligent_features.py @@ -3,45 +3,45 @@ from custom_components.octopus_energy.intelligent import get_intelligent_features @pytest.mark.asyncio -@pytest.mark.parametrize("provider,expected_bump_charge_supported,expected_charge_limit_supported,expected_planned_dispatches_supported,expected_ready_time_supported,expected_smart_charge_supported,expected_default_features",[ - ("DAIKIN", True, True, True, True, True, False), - ("ECOBEE", True, True, True, True, True, False), - ("ENERGIZER", True, True, True, True, True, False), - ("ENPHASE", True, True, True, True, True, False), - ("ENODE", True, True, True, True, True, False), - ("FORD", True, True, True, True, True, False), - ("GIVENERGY", True, True, True, True, True, False), - ("HUAWEI", True, True, True, True, True, False), - ("JEDLIX", True, True, True, True, True, False), - ("MYENERGI", True, True, True, True, True, False), - ("OCPP_WALLBOX", True, True, True, True, True, False), - ("SENSI", True, True, True, True, True, False), - ("SMARTCAR", True, True, True, True, True, False), - ("TESLA", True, True, True, True, True, False), - ("SMART_PEAR", True, True, True, True, True, False), - ("HYPERVOLT", True, True, True, True, True, False), - ("OHME", False, False, False, False, False, False), - ("DAIKIN".lower(), True, True, True, True, True, False), - ("ECOBEE".lower(), True, True, True, True, True, False), - ("ENERGIZER".lower(), True, True, True, True, True, False), - ("ENPHASE".lower(), True, True, True, True, True, False), - ("ENODE".lower(), True, True, True, True, True, False), - ("FORD".lower(), True, True, True, True, True, False), - ("GIVENERGY".lower(), True, True, True, True, True, False), - ("HUAWEI".lower(), True, True, True, True, True, False), - ("JEDLIX".lower(), True, True, True, True, True, False), - ("MYENERGI".lower(), True, True, True, True, True, False), - ("OCPP_WALLBOX".lower(), True, True, True, True, True, False), - ("SENSI".lower(), True, True, True, True, True, False), - ("SMARTCAR".lower(), True, True, True, True, True, False), - ("TESLA".lower(), True, True, True, True, True, False), - ("SMART_PEAR".lower(), True, True, True, True, True, False), - ("HYPERVOLT".lower(), True, True, True, True, True, False), - ("OHME".lower(), False, False, False, False, False, False), +@pytest.mark.parametrize("provider,expected_bump_charge_supported,expected_charge_limit_supported,expected_planned_dispatches_supported,expected_ready_time_supported,expected_smart_charge_supported,expected_default_features,expected_current_state_supported",[ + ("DAIKIN", True, True, True, True, True, False, True), + ("ECOBEE", True, True, True, True, True, False, True), + ("ENERGIZER", True, True, True, True, True, False, True), + ("ENPHASE", True, True, True, True, True, False, True), + ("ENODE", True, True, True, True, True, False, True), + ("FORD", True, True, True, True, True, False, True), + ("GIVENERGY", True, True, True, True, True, False, True), + ("HUAWEI", True, True, True, True, True, False, True), + ("JEDLIX", True, True, True, True, True, False, True), + ("MYENERGI", True, True, True, True, True, False, True), + ("OCPP_WALLBOX", True, True, True, True, True, False, True), + ("SENSI", True, True, True, True, True, False, True), + ("SMARTCAR", True, True, True, True, True, False, True), + ("TESLA", True, True, True, True, True, False, True), + ("SMART_PEAR", True, True, True, True, True, False, True), + ("HYPERVOLT", True, True, True, True, True, False, True), + ("OHME", False, False, False, False, False, False, False), + ("DAIKIN".lower(), True, True, True, True, True, False, True), + ("ECOBEE".lower(), True, True, True, True, True, False, True), + ("ENERGIZER".lower(), True, True, True, True, True, False, True), + ("ENPHASE".lower(), True, True, True, True, True, False, True), + ("ENODE".lower(), True, True, True, True, True, False, True), + ("FORD".lower(), True, True, True, True, True, False, True), + ("GIVENERGY".lower(), True, True, True, True, True, False, True), + ("HUAWEI".lower(), True, True, True, True, True, False, True), + ("JEDLIX".lower(), True, True, True, True, True, False, True), + ("MYENERGI".lower(), True, True, True, True, True, False, True), + ("OCPP_WALLBOX".lower(), True, True, True, True, True, False, True), + ("SENSI".lower(), True, True, True, True, True, False, True), + ("SMARTCAR".lower(), True, True, True, True, True, False, True), + ("TESLA".lower(), True, True, True, True, True, False, True), + ("SMART_PEAR".lower(), True, True, True, True, True, False, True), + ("HYPERVOLT".lower(), True, True, True, True, True, False, True), + ("OHME".lower(), False, False, False, False, False, False, False), # Unexpected providers - ("unexpected".lower(), False, False, False, False, False, True), - ("".lower(), False, False, False, False, False, True), - (None, False, False, False, False, False, True), + ("unexpected".lower(), False, False, False, False, False, True, False), + ("".lower(), False, False, False, False, False, True, False), + (None, False, False, False, False, False, True, False), ]) async def test_when_provider_provided_then_expected_features_returned( provider: str, @@ -50,7 +50,8 @@ async def test_when_provider_provided_then_expected_features_returned( expected_planned_dispatches_supported: bool, expected_ready_time_supported: bool, expected_smart_charge_supported: bool, - expected_default_features: bool + expected_default_features: bool, + expected_current_state_supported: bool ): # Act result = get_intelligent_features(provider) @@ -62,4 +63,5 @@ async def test_when_provider_provided_then_expected_features_returned( assert result.planned_dispatches_supported == expected_planned_dispatches_supported assert result.ready_time_supported == expected_ready_time_supported assert result.smart_charge_supported == expected_smart_charge_supported - assert result.is_default_features == expected_default_features \ No newline at end of file + assert result.is_default_features == expected_default_features + assert result.current_state_supported == expected_current_state_supported \ No newline at end of file From 52cc57862af04d152d5eda4ac05aec29dcf4cdc2 Mon Sep 17 00:00:00 2001 From: BottlecapDave Date: Thu, 26 Dec 2024 13:16:33 +0000 Subject: [PATCH 02/18] docs: Updated the docs --- _docs/entities/intelligent.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_docs/entities/intelligent.md b/_docs/entities/intelligent.md index df857d2c..e67e9952 100644 --- a/_docs/entities/intelligent.md +++ b/_docs/entities/intelligent.md @@ -56,7 +56,7 @@ Each item in `planned_dispatch` or `completed_dispatches` have the following att `sensor.octopus_energy_{{ACCOUNT_ID}}_intelligent_state` -This sensor displays the current state of your intelligent provider. The value of this sensor can be one of the following +This sensor displays the current state of your intelligent provider as told by the OE API. The value of this sensor can be one of the following * `AUTHENTICATION_PENDING` - ready to start authentication and authorization, or auth is in progress. * `AUTHENTICATION_FAILED` - failed to connect and ready to restart authentication and authorization. From 0d7f595cceecd1a45b56c7aa06266bbfd75129e5 Mon Sep 17 00:00:00 2001 From: BottlecapDave Date: Fri, 27 Dec 2024 19:27:20 +0000 Subject: [PATCH 03/18] blueprint: Added blueprint to detect gas anomalies (e.g. potential gas leaks) (30 minutes dev time) --- _docs/blueprints.md | 10 ++++ .../octopus_energy_gas_anomaly.yaml | 56 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 _docs/blueprints/octopus_energy_gas_anomaly.yaml diff --git a/_docs/blueprints.md b/_docs/blueprints.md index 3986ea0a..04b7a7e2 100644 --- a/_docs/blueprints.md +++ b/_docs/blueprints.md @@ -12,6 +12,16 @@ This blueprint will raise a persistent notification within HA when a rate update ## Consumption +### Alert when gas anomaly detected + +[Install blueprint](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2FBottlecapDave%2FHomeAssistant-OctopusEnergy%2Fblob%2Fdevelop%2F_docs%2Fblueprints%2Foctopus_energy_gas_anomaly.yaml) | [Source](./blueprints/octopus_energy_gas_anomaly.yaml) + +This blueprint will fire a configured action when the consumption has 24 hours worth of records and all thirty minute periods exceed the configured threshold. This can be used to alert you to _potential_ gas leaks. + +!!! warning + + Like everything else with this integration, this is provided _as is_ and should be used as a guide and early warning sign. It will only trigger if all data is available. If triggered, you should use your own judgment to determine if the warning is legitimate. + ### Alert when consumption data is late [Install blueprint](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2FBottlecapDave%2FHomeAssistant-OctopusEnergy%2Fblob%2Fdevelop%2F_docs%2Fblueprints%octopus_energy_late_consumption_data.yml) | [Source](./blueprints/octopus_energy_late_consumption_data.yml) diff --git a/_docs/blueprints/octopus_energy_gas_anomaly.yaml b/_docs/blueprints/octopus_energy_gas_anomaly.yaml new file mode 100644 index 00000000..a8e512f2 --- /dev/null +++ b/_docs/blueprints/octopus_energy_gas_anomaly.yaml @@ -0,0 +1,56 @@ +blueprint: + name: Octopus Energy - Gas Anomaly + description: Once all data is available for a day, detects if at least one 30 minute segment is equal or below the configured threshold. This is to try and detect potential gas leaks. If triggered, you should use your own judgment to determine if the warning is legitimate. + domain: automation + author: BottlecapDave + input: + consumption_entity: + name: Consumption entity + description: The entity which reports the previous accumulative consumption (e.g sensor.octopus_energy_electricity_{{METER_SERIAL_NUMBER}}_{{MPAN_NUMBER}}_previous_accumulative_consumption) + selector: + entity: + filter: + - domain: + - sensor + integration: octopus_energy + threshold: + name: Threshold + description: The threshold that at least one 30 minute segment must be equal or below + selector: + number: + mode: box + min: 0 + actions: + name: Actions + description: Notifications or similar to be run. {{message}} is an example message + selector: + action: {} +trigger_variables: + consumption_entity: !input consumption_entity + threshold: !input threshold +variables: + message: 'All 30 minute periods have exceeded the threshold of {{ threshold }}, which is an indication of a potential gas leak. Please review your consumption data and make your own assessments.' +mode: queued +max: 4 +trigger: +- platform: state + entity_id: !input consumption_entity +condition: + - condition: template + value_template: > + {% set consumptions = state_attr(consumption_entity, 'charges') %} + {% set unit_of_measurement = state_attr(consumption_entity, 'unit_of_measurement') %} + {% if consumptions != none and consumptions | length == 48 %} + {% set is_valid = namespace(value=True) %} + {% for consumption in consumptions %} + {% if (unit_of_measurement == "kWh" and consumption["consumption_kwh"] <= threshold) or (unit_of_measurement != "kWh" and consumption["consumption_m3"] <= threshold) %} + {% set is_valid.value = False %} + {% endif %} + {% endfor %} + {{ is_valid.value }} + {% else %} + {{ False }} + {% endif %} +action: +- choose: [] + default: !input actions From 08dc4f60592238febf881de90048a0cc0ad63b36 Mon Sep 17 00:00:00 2001 From: BottlecapDave Date: Sun, 29 Dec 2024 11:18:31 +0000 Subject: [PATCH 04/18] docs: Updated intelligent migration docs to reference target and rolling target rate sensors --- _docs/entities/intelligent.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_docs/entities/intelligent.md b/_docs/entities/intelligent.md index 68dc2a88..6ded8a75 100644 --- a/_docs/entities/intelligent.md +++ b/_docs/entities/intelligent.md @@ -122,7 +122,7 @@ If you're moving to this integration from [megakid/ha_octopus_intelligent](https * `binary_sensor.octopus_intelligent_slot` - Use the [is dispatching sensor](#is-dispatching) * `binary_sensor.octopus_intelligent_planned_dispatch_slot` - There is no alternative for this. -* `binary_sensor.octopus_intelligent_slot_next_1_hour`, `binary_sensor.octopus_intelligent_slot_next_2_hours` and `binary_sensor.octopus_intelligent_slot_next_3_hours` - These sensors felt like they would always fall short of peoples requirements as everyone has different time periods they wish to know about. The [is dispatching sensor](#is-dispatching) exposes the current and next start/end times which could be used in a template sensor to determine how long the rate is cheap for when on. +* `binary_sensor.octopus_intelligent_slot_next_1_hour`, `binary_sensor.octopus_intelligent_slot_next_2_hours` and `binary_sensor.octopus_intelligent_slot_next_3_hours` - These sensors felt like they would always fall short of peoples requirements as everyone has different time periods they wish to know about. The [is dispatching sensor](#is-dispatching) exposes the current and next start/end times which could be used in a template sensor to determine how long the rate is cheap for when on. Or the [target rate](../setup/target_rate.md) or [rolling target rate](../setup/rolling_target_rate.md) might help for what you need. * `sensor.octopus_intelligent_next_offpeak_start` - The default off peak start date/time can be found as an attribute on the [off peak sensor](./electricity.md#off-peak). This can be extracted using a [template sensor](https://www.home-assistant.io/integrations/template/). * `sensor.octopus_intelligent_offpeak_end` - The default off peak end date/time can be found as an attribute on the [off peak sensor](./electricity.md#off-peak). This can be extracted using a [template sensor](https://www.home-assistant.io/integrations/template/). * `switch.octopus_intelligent_bump_charge` - Use the [bump charge sensor](#bump-charge) From 52bb498905e3db43f1069961fe651c7b35f00afe Mon Sep 17 00:00:00 2001 From: BottlecapDave Date: Sun, 29 Dec 2024 11:41:34 +0000 Subject: [PATCH 05/18] fix: Fixed intelligent charge target to not send invalid value to OE (10 minutes dev time) --- .../intelligent/charge_target.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/custom_components/octopus_energy/intelligent/charge_target.py b/custom_components/octopus_energy/intelligent/charge_target.py index 7ca19e41..400008b4 100644 --- a/custom_components/octopus_energy/intelligent/charge_target.py +++ b/custom_components/octopus_energy/intelligent/charge_target.py @@ -91,14 +91,17 @@ def _handle_coordinator_update(self) -> None: async def async_set_native_value(self, value: float) -> None: """Set new value.""" - await self._client.async_update_intelligent_car_target_percentage( - self._account_id, - self._device.id, - int(value) - ) - self._state = value - self._last_updated = utcnow() - self.async_write_ha_state() + if value and value % self._attr_native_step == 0: + await self._client.async_update_intelligent_car_target_percentage( + self._account_id, + self._device.id, + int(value) + ) + self._state = value + self._last_updated = utcnow() + self.async_write_ha_state() + else: + raise Exception(f"Value must be between {self._attr_native_min_value} and {self._attr_native_max_value} and be a multiple of 5") async def async_added_to_hass(self) -> None: """Restore last state.""" From 7554228bdc507e1f1f918f63d50874c995db6d5f Mon Sep 17 00:00:00 2001 From: BottlecapDave Date: Sun, 29 Dec 2024 12:15:02 +0000 Subject: [PATCH 06/18] feat: Added select sensor for intelligent target time to make it easier to pick a valid time. The existing time sensor is deprecated and will be removed in a future release (45 minutes dev time) --- _docs/entities/intelligent.md | 6 +- custom_components/octopus_energy/__init__.py | 2 +- .../intelligent/target_time_select.py | 117 ++++++++++++++++++ custom_components/octopus_energy/select.py | 51 ++++++++ custom_components/octopus_energy/time.py | 13 ++ .../octopus_energy/translations/en.json | 4 + 6 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 custom_components/octopus_energy/intelligent/target_time_select.py create mode 100644 custom_components/octopus_energy/select.py diff --git a/_docs/entities/intelligent.md b/_docs/entities/intelligent.md index 6ded8a75..2c158700 100644 --- a/_docs/entities/intelligent.md +++ b/_docs/entities/intelligent.md @@ -102,7 +102,7 @@ This sensor is used to see and set the charge target for your future intelligent ### Target Time -`time.octopus_energy_{{ACCOUNT_ID}}_intelligent_target_time` +`select.octopus_energy_{{ACCOUNT_ID}}_intelligent_target_time` This sensor is used to see and set the target time for your future intelligent charges. @@ -116,6 +116,10 @@ This sensor is used to see and set the target time for your future intelligent c You can use the [data_last_retrieved sensor](./diagnostics.md#intelligent-settings-data-last-retrieved) to determine when the underlying data was last retrieved from the OE servers. +!!! warning + + There is a time based sensor called `select.octopus_energy_{{ACCOUNT_ID}}_intelligent_target_time` which represents this functionality. This is a legacy sensor which will be removed in the future. + ## Migrating from megakid/ha_octopus_intelligent? If you're moving to this integration from [megakid/ha_octopus_intelligent](https://github.com/megakid/ha_octopus_intelligent), below is a quick guide on what entities you should use diff --git a/custom_components/octopus_energy/__init__.py b/custom_components/octopus_energy/__init__.py index 393fbde6..6c14f344 100644 --- a/custom_components/octopus_energy/__init__.py +++ b/custom_components/octopus_energy/__init__.py @@ -67,7 +67,7 @@ REPAIR_UNKNOWN_INTELLIGENT_PROVIDER ) -ACCOUNT_PLATFORMS = ["sensor", "binary_sensor", "number", "switch", "text", "time", "event"] +ACCOUNT_PLATFORMS = ["sensor", "binary_sensor", "number", "switch", "text", "time", "event", "select"] TARGET_RATE_PLATFORMS = ["binary_sensor"] COST_TRACKER_PLATFORMS = ["sensor"] TARIFF_COMPARISON_PLATFORMS = ["sensor"] diff --git a/custom_components/octopus_energy/intelligent/target_time_select.py b/custom_components/octopus_energy/intelligent/target_time_select.py new file mode 100644 index 00000000..2ec2c30a --- /dev/null +++ b/custom_components/octopus_energy/intelligent/target_time_select.py @@ -0,0 +1,117 @@ +import logging +from datetime import datetime, time, timedelta +import time as time_time + +from homeassistant.const import ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import generate_entity_id + +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity +) +from homeassistant.components.select import SelectEntity +from homeassistant.util.dt import (utcnow) +from homeassistant.helpers.restore_state import RestoreEntity + +from .base import OctopusEnergyIntelligentSensor +from ..api_client import OctopusEnergyApiClient +from ..coordinators.intelligent_settings import IntelligentCoordinatorResult +from ..utils.attributes import dict_to_typed_dict + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyIntelligentTargetTimeSelect(CoordinatorEntity, SelectEntity, OctopusEnergyIntelligentSensor, RestoreEntity): + """Sensor for setting the target time to charge the car to the desired percentage.""" + + def __init__(self, hass: HomeAssistant, coordinator, client: OctopusEnergyApiClient, device, account_id: str): + """Init sensor.""" + # Pass coordinator to base class + CoordinatorEntity.__init__(self, coordinator) + OctopusEnergyIntelligentSensor.__init__(self, device) + + self._state = None + self._last_updated = None + self._client = client + self._account_id = account_id + self._attributes = {} + self.entity_id = generate_entity_id("select.{}", self.unique_id, hass=hass) + + self._options = [] + current_time = datetime(2025, 1, 1, 4, 0) + final_time = datetime(2025, 1, 1, 11, 30) + while current_time < final_time: + self._options.append(f"{current_time.hour:02}:{current_time.minute:02}") + current_time = current_time + timedelta(minutes=30) + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_{self._account_id}_intelligent_target_time" + + @property + def name(self): + """Name of the sensor.""" + return f"Intelligent Target Time ({self._account_id})" + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:battery-clock" + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def options(self) -> list[str]: + """Return the available tariffs.""" + return self._options + + @property + def current_option(self) -> str: + return self._state + + @callback + def _handle_coordinator_update(self) -> None: + """The time that the car should be ready by.""" + settings_result: IntelligentCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None + if settings_result is None or (self._last_updated is not None and self._last_updated > settings_result.last_retrieved): + return + + if settings_result.settings is not None: + self._state = f"{settings_result.settings.ready_time_weekday.hour:02}:{settings_result.settings.ready_time_weekday.minute:02}" + + self._attributes = dict_to_typed_dict(self._attributes) + super()._handle_coordinator_update() + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + parts = option.split(":") + value = time(int(parts[0]), int(parts[1])) + await self._client.async_update_intelligent_car_target_time( + self._account_id, + self._device.id, + value, + ) + self._state = value + self._last_updated = utcnow() + self.async_write_ha_state() + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None: + self._state = None if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) else state.state + self._attributes = dict_to_typed_dict(state.attributes) + + if (self._state is None): + self._state = None + + _LOGGER.debug(f'Restored OctopusEnergyIntelligentTargetTime state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/select.py b/custom_components/octopus_energy/select.py new file mode 100644 index 00000000..0e53773f --- /dev/null +++ b/custom_components/octopus_energy/select.py @@ -0,0 +1,51 @@ +import logging + +from .intelligent.target_time_select import OctopusEnergyIntelligentTargetTimeSelect +from .api_client import OctopusEnergyApiClient +from .intelligent import get_intelligent_features +from .api_client.intelligent_device import IntelligentDevice + +from .const import ( + CONFIG_ACCOUNT_ID, + DATA_CLIENT, + DATA_INTELLIGENT_DEVICE, + DOMAIN, + + CONFIG_MAIN_API_KEY, + + DATA_INTELLIGENT_SETTINGS_COORDINATOR +) + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass, entry, async_add_entities): + """Setup sensors based on our entry""" + + config = dict(entry.data) + + if entry.options: + config.update(entry.options) + + if CONFIG_MAIN_API_KEY in config: + await async_setup_intelligent_sensors(hass, config, async_add_entities) + + return True + +async def async_setup_intelligent_sensors(hass, config, async_add_entities): + _LOGGER.debug('Setting up intelligent sensors') + + entities = [] + + account_id = config[CONFIG_ACCOUNT_ID] + + client = hass.data[DOMAIN][account_id][DATA_CLIENT] + 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) + settings_coordinator = hass.data[DOMAIN][account_id][DATA_INTELLIGENT_SETTINGS_COORDINATOR] + client: OctopusEnergyApiClient = hass.data[DOMAIN][account_id][DATA_CLIENT] + + if intelligent_features.ready_time_supported: + entities.append(OctopusEnergyIntelligentTargetTimeSelect(hass, settings_coordinator, client, intelligent_device, account_id)) + + async_add_entities(entities) \ No newline at end of file diff --git a/custom_components/octopus_energy/time.py b/custom_components/octopus_energy/time.py index f29a9c14..ed90b138 100644 --- a/custom_components/octopus_energy/time.py +++ b/custom_components/octopus_energy/time.py @@ -1,5 +1,7 @@ import logging +from homeassistant.helpers import issue_registry as ir + from .intelligent.target_time import OctopusEnergyIntelligentTargetTime from .api_client import OctopusEnergyApiClient from .intelligent import get_intelligent_features @@ -46,6 +48,17 @@ async def async_setup_intelligent_sensors(hass, config, async_add_entities): client: OctopusEnergyApiClient = hass.data[DOMAIN][account_id][DATA_CLIENT] if intelligent_features.ready_time_supported: + ir.async_create_issue( + hass, + DOMAIN, + "intelligent_target_time_deprecated", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="intelligent_target_time_deprecated", + translation_placeholders={ "account_id": account_id }, + learn_more_url="https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/issues/1079", + ) + entities.append(OctopusEnergyIntelligentTargetTime(hass, settings_coordinator, client, intelligent_device, account_id)) async_add_entities(entities) \ No newline at end of file diff --git a/custom_components/octopus_energy/translations/en.json b/custom_components/octopus_energy/translations/en.json index 79c66c64..cfe4dde8 100644 --- a/custom_components/octopus_energy/translations/en.json +++ b/custom_components/octopus_energy/translations/en.json @@ -318,6 +318,10 @@ "unknown_intelligent_provider": { "title": "Unknown intelligent provider \"{provider}\"", "description": "You have an intelligent provider of \"{provider}\" which is not recognised and therefore a reduced feature set has been enabled. Click on \"Learn More\" with instructions on what to do next." + }, + "intelligent_target_time_deprecated": { + "title": "Intelligent target time sensor has been deprecated", + "description": "The target time sensor (defaults to time.octopus_energy_{account_id}_intelligent_target_time) has been deprecated in favour of a select based sensor (select.octopus_energy_{account_id}_intelligent_target_time) to make it easier to select a valid time. This old sensor will be removed in a future release." } } } \ No newline at end of file From 654dd95b794da87606a465ee3213729b3e4c43b0 Mon Sep 17 00:00:00 2001 From: BottlecapDave Date: Mon, 30 Dec 2024 05:08:25 +0000 Subject: [PATCH 07/18] chore: Updated configuration headings --- custom_components/octopus_energy/translations/en.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/custom_components/octopus_energy/translations/en.json b/custom_components/octopus_energy/translations/en.json index cfe4dde8..bdd2a422 100644 --- a/custom_components/octopus_energy/translations/en.json +++ b/custom_components/octopus_energy/translations/en.json @@ -8,8 +8,8 @@ "account_id": "Account ID (e.g. A-AAAA1111)", "api_key": "Api key", "supports_live_consumption": "I have a Home Mini - https://octopus.energy/blog/octopus-home-mini/", - "live_electricity_consumption_refresh_in_minutes": "Home Mini/Pro electricity refresh rate in minutes", - "live_gas_consumption_refresh_in_minutes": "Home Mini/Pro gas refresh rate in minutes", + "live_electricity_consumption_refresh_in_minutes": "Home Mini electricity refresh rate in minutes", + "live_gas_consumption_refresh_in_minutes": "Home Mini gas refresh rate in minutes", "calorific_value": "Gas calorific value.", "electricity_price_cap": "Optional electricity price cap in pence", "gas_price_cap": "Optional gas price cap in pence", @@ -156,8 +156,8 @@ "data": { "api_key": "Api key", "supports_live_consumption": "I have a Home Mini - https://octopus.energy/blog/octopus-home-mini/", - "live_electricity_consumption_refresh_in_minutes": "Home Mini/Pro electricity refresh rate in minutes", - "live_gas_consumption_refresh_in_minutes": "Home Mini/Pro gas refresh rate in minutes", + "live_electricity_consumption_refresh_in_minutes": "Home Mini electricity refresh rate in minutes", + "live_gas_consumption_refresh_in_minutes": "Home Mini gas refresh rate in minutes", "calorific_value": "Gas calorific value", "electricity_price_cap": "Optional electricity price cap in pence", "gas_price_cap": "Optional gas price cap in pence", From 5701e4dfba2d4344e5822e25f153397792f81b51 Mon Sep 17 00:00:00 2001 From: BottlecapDave Date: Mon, 30 Dec 2024 08:51:55 +0000 Subject: [PATCH 08/18] fix: Updated cost tracker sensor configuration to update when underlying entity id changes (30 minutes dev time) --- .../cost_tracker/cost_tracker.py | 32 ++++++++++++++++++- custom_components/octopus_energy/sensor.py | 4 +-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/custom_components/octopus_energy/cost_tracker/cost_tracker.py b/custom_components/octopus_energy/cost_tracker/cost_tracker.py index f934ce5e..a404a4da 100644 --- a/custom_components/octopus_energy/cost_tracker/cost_tracker.py +++ b/custom_components/octopus_energy/cost_tracker/cost_tracker.py @@ -18,6 +18,7 @@ from homeassistant.helpers.event import ( EventStateChangedData, async_track_state_change_event, + async_track_entity_registry_updated_event, ) from homeassistant.const import ( @@ -43,12 +44,13 @@ class OctopusEnergyCostTrackerSensor(CoordinatorEntity, RestoreSensor): """Sensor for calculating the cost for a given sensor.""" - def __init__(self, hass: HomeAssistant, coordinator, config, device_entry, peak_type = None): + def __init__(self, hass: HomeAssistant, coordinator, config_entry, config, device_entry, peak_type = None): """Init sensor.""" # Pass coordinator to base class CoordinatorEntity.__init__(self, coordinator) self._state = None + self._config_entry = config_entry self._config = config self._attributes = self._config.copy() self._attributes["is_tracking"] = True @@ -146,6 +148,34 @@ async def async_added_to_hass(self): ) ) + self.async_on_remove( + async_track_entity_registry_updated_event( + self.hass, [self._config[CONFIG_COST_TRACKER_TARGET_ENTITY_ID]], self._async_update_tracked_entity + ) + ) + + async def _async_update_tracked_entity(self, event) -> None: + data = event.data + if data["action"] != "update": + return + + if "entity_id" in data["changes"]: + new_entity_id = data["entity_id"] + if new_entity_id != self._config[CONFIG_COST_TRACKER_TARGET_ENTITY_ID]: + self._hass.config_entries.async_update_entry( + self._config_entry, + data={ + **self._config_entry.data, + CONFIG_COST_TRACKER_TARGET_ENTITY_ID: new_entity_id, + }, + options = { + **self._config_entry.options, + CONFIG_COST_TRACKER_TARGET_ENTITY_ID: new_entity_id, + } + ) + _LOGGER.debug(f"Tracked entity for '{self.entity_id}' updated from '{self._config[CONFIG_COST_TRACKER_TARGET_ENTITY_ID]}' to '{new_entity_id}'. Reloading...") + await self._hass.config_entries.async_reload(self._config_entry.entry_id) + async def _async_calculate_cost(self, event: Event[EventStateChangedData]): new_state = event.data["new_state"] old_state = event.data["old_state"] diff --git a/custom_components/octopus_energy/sensor.py b/custom_components/octopus_energy/sensor.py index 0f516371..3cb999ca 100644 --- a/custom_components/octopus_energy/sensor.py +++ b/custom_components/octopus_energy/sensor.py @@ -565,7 +565,7 @@ async def async_setup_cost_sensors(hass: HomeAssistant, entry, config, async_add if device_id is not None: device_entry = device_registry.async_get(device_id) - sensor = OctopusEnergyCostTrackerSensor(hass, coordinator, config, device_entry) + sensor = OctopusEnergyCostTrackerSensor(hass, coordinator, entry, config, device_entry) sensor_entity_id = registry.async_get_entity_id("sensor", DOMAIN, sensor.unique_id) entities = [ @@ -580,7 +580,7 @@ async def async_setup_cost_sensors(hass: HomeAssistant, entry, config, async_add 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, device_entry, peak_type) + peak_sensor = OctopusEnergyCostTrackerSensor(hass, coordinator, entry, config, device_entry, peak_type) peak_sensor_entity_id = registry.async_get_entity_id("sensor", DOMAIN, peak_sensor.unique_id) entities.append(peak_sensor) From 6cdffb02ffb59543c707eeb6987baeaf35b17bbe Mon Sep 17 00:00:00 2001 From: BottlecapDave Date: Mon, 30 Dec 2024 09:52:00 +0000 Subject: [PATCH 09/18] feat: Updated update target rate and rolling target rate services to support persisting changes between restarts (1 hour dev time) --- _docs/services.md | 2 ++ .../octopus_energy/binary_sensor.py | 6 ++++-- .../octopus_energy/services.yaml | 12 ++++++++++- .../octopus_energy/target_rates/__init__.py | 8 +++++++ .../target_rates/rolling_target_rate.py | 21 ++++++++++++++++--- .../target_rates/target_rate.py | 21 ++++++++++++++++--- 6 files changed, 61 insertions(+), 9 deletions(-) diff --git a/_docs/services.md b/_docs/services.md index bfd7d827..81087360 100644 --- a/_docs/services.md +++ b/_docs/services.md @@ -33,6 +33,7 @@ For updating a given [target rate's](./setup/target_rate.md) config. This allows | `data.target_minimum_rate` | `yes` | The optional minimum rate the selected rates should not go below. | | `data.target_maximum_rate` | `yes` | The optional maximum rate the selected rates should not go above. | | `data.target_weighting` | `yes` | The optional weighting that should be applied to the selected rates. | +| `data.persist_changes` | `yes` | Determines if the changes should be persisted to the original configuration or should be temporary and reset upon integration reload. If not supplied, then the changes are temporary | ### Automation Example @@ -103,6 +104,7 @@ For updating a given [rolling target rate's](./setup/rolling_target_rate.md) con | `data.target_minimum_rate` | `yes` | The optional minimum rate the selected rates should not go below. | | `data.target_maximum_rate` | `yes` | The optional maximum rate the selected rates should not go above. | | `data.target_weighting` | `yes` | The optional weighting that should be applied to the selected rates. | +| `data.persist_changes` | `yes` | Determines if the changes should be persisted to the original configuration or should be temporary and reset upon integration reload. If not supplied, then the changes are temporary | ### Automation Example diff --git a/custom_components/octopus_energy/binary_sensor.py b/custom_components/octopus_energy/binary_sensor.py index b7b18e04..c70b6a35 100644 --- a/custom_components/octopus_energy/binary_sensor.py +++ b/custom_components/octopus_energy/binary_sensor.py @@ -62,6 +62,7 @@ async def async_setup_entry(hass, entry, async_add_entities): vol.Optional("target_minimum_rate"): str, vol.Optional("target_maximum_rate"): str, vol.Optional("target_weighting"): str, + vol.Optional("persist_changes"): bool, }, extra=vol.ALLOW_EXTRA, ), @@ -83,6 +84,7 @@ async def async_setup_entry(hass, entry, async_add_entities): vol.Optional("target_minimum_rate"): str, vol.Optional("target_maximum_rate"): str, vol.Optional("target_weighting"): str, + vol.Optional("persist_changes"): bool, }, extra=vol.ALLOW_EXTRA, ), @@ -172,9 +174,9 @@ async def async_setup_target_sensors(hass, entry, async_add_entities): entities = [] if config[CONFIG_KIND] == CONFIG_KIND_TARGET_RATE: - entities.append(OctopusEnergyTargetRate(hass, account_id, config, is_export, coordinator, free_electricity_coordinator)) + entities.append(OctopusEnergyTargetRate(hass, account_id, entry, config, is_export, coordinator, free_electricity_coordinator)) else: - entities.append(OctopusEnergyRollingTargetRate(hass, account_id, config, is_export, coordinator, free_electricity_coordinator)) + entities.append(OctopusEnergyRollingTargetRate(hass, account_id, entry, config, is_export, coordinator, free_electricity_coordinator)) async_add_entities(entities) return diff --git a/custom_components/octopus_energy/services.yaml b/custom_components/octopus_energy/services.yaml index af826439..7809cf70 100644 --- a/custom_components/octopus_energy/services.yaml +++ b/custom_components/octopus_energy/services.yaml @@ -1,6 +1,6 @@ update_target_config: name: Update target rate config - description: Updates a given target rate's config. Please note this is temporary and will not persist between restarts. + description: Updates a given target rate's config. target: entity: integration: octopus_energy @@ -51,6 +51,11 @@ update_target_config: example: '1,2,1' selector: text: + persist_changes: + name: Persist Changes + description: Determines if the changes should be persisted to the original configuration or should be temporary and reset upon integration reload. + selector: + boolean: update_rolling_target_config: name: Update rolling target rate config @@ -99,6 +104,11 @@ update_rolling_target_config: example: '1,2,1' selector: text: + persist_changes: + name: Persist Changes + description: Determines if the changes should be persisted to the original configuration or should be temporary and reset upon integration reload. + selector: + boolean: purge_invalid_external_statistic_ids: name: Purge invalid external statistics diff --git a/custom_components/octopus_energy/target_rates/__init__.py b/custom_components/octopus_energy/target_rates/__init__.py index f0a85739..6d520ddd 100644 --- a/custom_components/octopus_energy/target_rates/__init__.py +++ b/custom_components/octopus_energy/target_rates/__init__.py @@ -12,6 +12,14 @@ _LOGGER = logging.getLogger(__name__) +def extract_config(config: dict, keys: list[str]): + new_config = {} + for key in config.keys(): + if key in keys: + new_config[key] = config[key] + + return new_config + def apply_offset(date_time: datetime, offset: str, inverse = False): matches = re.search(REGEX_OFFSET_PARTS, offset) if matches == None: diff --git a/custom_components/octopus_energy/target_rates/rolling_target_rate.py b/custom_components/octopus_energy/target_rates/rolling_target_rate.py index 3ae3d2bb..8bf7ee60 100644 --- a/custom_components/octopus_energy/target_rates/rolling_target_rate.py +++ b/custom_components/octopus_energy/target_rates/rolling_target_rate.py @@ -48,6 +48,7 @@ calculate_intermittent_times, compare_config, create_weighting, + extract_config, get_rates, get_target_rate_info, should_evaluate_target_rates @@ -68,12 +69,13 @@ class OctopusEnergyRollingTargetRate(MultiCoordinatorEntity, BinarySensorEntity, _unrecorded_attributes = frozenset({"data_last_retrieved", "target_times_last_evaluated"}) - def __init__(self, hass: HomeAssistant, account_id: str, config, is_export, coordinator, free_electricity_coordinator): + def __init__(self, hass: HomeAssistant, account_id: str, config_entry, config, is_export, coordinator, free_electricity_coordinator): """Init sensor.""" # Pass coordinator to base class MultiCoordinatorEntity.__init__(self, coordinator, [free_electricity_coordinator]) self._state = None + self._config_entry = config_entry self._config = config self._is_export = is_export self._attributes = self._config.copy() @@ -282,7 +284,7 @@ async def async_added_to_hass(self): _LOGGER.debug(f'Restored OctopusEnergyTargetRate state: {self._state}') @callback - async def async_update_rolling_target_rate_config(self, target_hours=None, target_look_ahead_hours=None, target_offset=None, target_minimum_rate=None, target_maximum_rate=None, target_weighting=None): + async def async_update_rolling_target_rate_config(self, target_hours=None, target_look_ahead_hours=None, target_offset=None, target_minimum_rate=None, target_maximum_rate=None, target_weighting=None, persist_changes=False): """Update sensors config""" config = dict(self._config) @@ -338,4 +340,17 @@ async def async_update_rolling_target_rate_config(self, target_hours=None, targe self._attributes = self._config.copy() self._attributes["is_target_export"] = self._is_export self._target_rates = [] - self.async_write_ha_state() \ No newline at end of file + self.async_write_ha_state() + + if persist_changes: + updatable_keys = [CONFIG_TARGET_HOURS, CONFIG_ROLLING_TARGET_HOURS_LOOK_AHEAD, CONFIG_TARGET_OFFSET, CONFIG_TARGET_MIN_RATE, CONFIG_TARGET_MAX_RATE, CONFIG_TARGET_WEIGHTING] + new_config_data = { **self._config_entry.data } + new_config_data.update(extract_config(config, updatable_keys)) + new_config_options = { **self._config_entry.options } + new_config_options.update(extract_config(config, updatable_keys)) + + self._hass.config_entries.async_update_entry( + self._config_entry, + data = new_config_data, + options = new_config_options + ) \ 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 2408604f..8e8a51a6 100644 --- a/custom_components/octopus_energy/target_rates/target_rate.py +++ b/custom_components/octopus_energy/target_rates/target_rate.py @@ -56,6 +56,7 @@ calculate_intermittent_times, compare_config, create_weighting, + extract_config, get_applicable_rates, get_target_rate_info, should_evaluate_target_rates @@ -75,12 +76,13 @@ class OctopusEnergyTargetRate(MultiCoordinatorEntity, BinarySensorEntity, Restor _unrecorded_attributes = frozenset({"data_last_retrieved", "target_times_last_evaluated"}) - def __init__(self, hass: HomeAssistant, account_id: str, config, is_export, coordinator, free_electricity_coordinator): + def __init__(self, hass: HomeAssistant, account_id: str, config_entry, config, is_export, coordinator, free_electricity_coordinator): """Init sensor.""" # Pass coordinator to base class MultiCoordinatorEntity.__init__(self, coordinator, [free_electricity_coordinator]) self._state = None + self._config_entry = config_entry self._config = config self._is_export = is_export self._attributes = self._config.copy() @@ -303,7 +305,7 @@ async def async_added_to_hass(self): _LOGGER.debug(f'Restored OctopusEnergyTargetRate state: {self._state}') @callback - async def async_update_target_rate_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): + async def async_update_target_rate_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, persist_changes=False): """Update sensors config""" config = dict(self._config) @@ -369,4 +371,17 @@ async def async_update_target_rate_config(self, target_start_time=None, target_e self._attributes = self._config.copy() self._attributes["is_target_export"] = self._is_export self._target_rates = [] - self.async_write_ha_state() \ No newline at end of file + self.async_write_ha_state() + + if persist_changes: + updatable_keys = [CONFIG_TARGET_HOURS, CONFIG_TARGET_START_TIME, CONFIG_TARGET_END_TIME, CONFIG_TARGET_OFFSET, CONFIG_TARGET_MIN_RATE, CONFIG_TARGET_MAX_RATE, CONFIG_TARGET_WEIGHTING] + new_config_data = { **self._config_entry.data } + new_config_data.update(extract_config(config, updatable_keys)) + new_config_options = { **self._config_entry.options } + new_config_options.update(extract_config(config, updatable_keys)) + + self._hass.config_entries.async_update_entry( + self._config_entry, + data = new_config_data, + options = new_config_options + ) \ No newline at end of file From e2dc331aca8a6cb1706c01e72873b6a965be89e2 Mon Sep 17 00:00:00 2001 From: BottlecapDave Date: Mon, 30 Dec 2024 12:23:13 +0000 Subject: [PATCH 10/18] fix: Fixed guard against invalid data in home pro client --- .../octopus_energy/api_client_home_pro/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/octopus_energy/api_client_home_pro/__init__.py b/custom_components/octopus_energy/api_client_home_pro/__init__.py index df5e11d9..1fee6669 100644 --- a/custom_components/octopus_energy/api_client_home_pro/__init__.py +++ b/custom_components/octopus_energy/api_client_home_pro/__init__.py @@ -66,7 +66,7 @@ async def async_get_consumption(self, is_electricity: bool) -> list | None: data = { "meter_type": meter_type } async with client.post(url, json=data) as response: response_body = await self.__async_read_response__(response, url) - if (response_body is not None and "meter_consump"): + if (response_body is not None and "meter_consump" in response_body): meter_consump = json.loads(response_body["meter_consump"]) if "consum" in meter_consump: data = meter_consump["consum"] From c14e5a2f5611465047255128c9adeacce150cdd0 Mon Sep 17 00:00:00 2001 From: BottlecapDave Date: Mon, 30 Dec 2024 16:29:22 +0000 Subject: [PATCH 11/18] chore: Fixed intelligent current state --- custom_components/octopus_energy/intelligent/current_state.py | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/octopus_energy/intelligent/current_state.py b/custom_components/octopus_energy/intelligent/current_state.py index 8913b77e..264bd788 100644 --- a/custom_components/octopus_energy/intelligent/current_state.py +++ b/custom_components/octopus_energy/intelligent/current_state.py @@ -33,6 +33,7 @@ def __init__(self, hass: HomeAssistant, coordinator, device: IntelligentDevice, self._account_id = account_id self._state = None + self._attributes = {} self.entity_id = generate_entity_id("sensor.{}", self.unique_id, hass=hass) From 7d59da8c550c0250a44102258e099afdf963dc92 Mon Sep 17 00:00:00 2001 From: David Kendall Date: Mon, 30 Dec 2024 19:33:46 +0000 Subject: [PATCH 12/18] feat: Added Cosy 6 Heat Pump support (10 hours 45 minutes dev time) --- README.md | 1 + _docs/entities/diagnostics.md | 15 + _docs/entities/heat_pump.md | 31 ++ _docs/index.md | 7 +- _docs/services.md | 13 +- custom_components/octopus_energy/__init__.py | 61 +-- .../octopus_energy/api_client/__init__.py | 164 ++++---- .../octopus_energy/api_client/heat_pump.py | 127 +++++++ custom_components/octopus_energy/climate.py | 103 ++++++ custom_components/octopus_energy/const.py | 5 + .../heat_pump_configuration_and_status.py | 124 +++++++ .../coordinators/intelligent_dispatches.py | 2 +- .../coordinators/intelligent_settings.py | 2 +- .../octopus_energy/diagnostics.py | 27 +- .../heat_pump_data_last_retrieved.py | 20 + .../octopus_energy/heat_pump/__init__.py | 350 ++++++++++++++++++ .../octopus_energy/heat_pump/base.py | 56 +++ .../heat_pump/sensor_humidity.py | 116 ++++++ .../heat_pump/sensor_temperature.py | 117 ++++++ .../octopus_energy/heat_pump/zone.py | 246 ++++++++++++ custom_components/octopus_energy/icons.json | 3 +- .../octoplus/free_electricity_sessions.py | 2 +- custom_components/octopus_energy/sensor.py | 71 +++- .../octopus_energy/services.yaml | 43 ++- .../octopus_energy/storage/heat_pump.py | 23 ++ .../storage/intelligent_device.py | 18 +- .../octopus_energy/translations/en.json | 3 + .../octopus_energy/utils/debug_overrides.py | 41 +- mkdocs.yml | 1 + requirements.test.txt | 1 + .../api_client/test_get_account.py | 4 + .../integration/test_async_get_diagnostics.py | 25 +- tests/unit/api_client/__init__.py | 0 tests/unit/api_client/test_heat_pump.py | 346 +++++++++++++++++ 34 files changed, 2016 insertions(+), 152 deletions(-) create mode 100644 _docs/entities/heat_pump.md create mode 100644 custom_components/octopus_energy/api_client/heat_pump.py create mode 100644 custom_components/octopus_energy/climate.py create mode 100644 custom_components/octopus_energy/coordinators/heat_pump_configuration_and_status.py create mode 100644 custom_components/octopus_energy/diagnostics_entities/heat_pump_data_last_retrieved.py create mode 100644 custom_components/octopus_energy/heat_pump/__init__.py create mode 100644 custom_components/octopus_energy/heat_pump/base.py create mode 100644 custom_components/octopus_energy/heat_pump/sensor_humidity.py create mode 100644 custom_components/octopus_energy/heat_pump/sensor_temperature.py create mode 100644 custom_components/octopus_energy/heat_pump/zone.py create mode 100644 custom_components/octopus_energy/storage/heat_pump.py create mode 100644 tests/unit/api_client/__init__.py create mode 100644 tests/unit/api_client/test_heat_pump.py diff --git a/README.md b/README.md index 0f6f8cc0..39800072 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Below are the main features of the integration * [Saving sessions support](https://bottlecapdave.github.io/HomeAssistant-OctopusEnergy/entities/octoplus/#saving-sessions) * [Wheel of fortune support](https://bottlecapdave.github.io/HomeAssistant-OctopusEnergy/entities/wheel_of_fortune/) * [Greener days support](https://bottlecapdave.github.io/HomeAssistant-OctopusEnergy/entities/greenness_forecast) +* [Heat Pump support](https://bottlecapdave.github.io/HomeAssistant-OctopusEnergy/entities/heat_pump) ## How to install diff --git a/_docs/entities/diagnostics.md b/_docs/entities/diagnostics.md index baaf4ef0..dde53baa 100644 --- a/_docs/entities/diagnostics.md +++ b/_docs/entities/diagnostics.md @@ -173,6 +173,21 @@ This sensor states when the standing charge data was last retrieved. This sensor states when wheel of fortune data was last retrieved. +!!! note + This is [disabled by default](../faq.md#there-are-entities-that-are-disabled-why-are-they-disabled-and-how-do-i-enable-them). + +| Attribute | Type | Description | +|-----------|------|-------------| +| `attempts` | `integer` | The number of attempts that have been made to retrieve the data | +| `next_refresh` | `datetime` | The timestamp of when the data will next be attempted to be retrieved | +| `last_error` | `string` | The error that was raised to cause the last retrieval attempt to fail | + +## Heat Pump Data Last Retrieved + +`sensor.octopus_energy_{{HEAT_PUMP_ID}}_heat_pump_data_last_retrieved` + +This sensor states when heat pump data was last retrieved. + !!! note This is [disabled by default](../faq.md#there-are-entities-that-are-disabled-why-are-they-disabled-and-how-do-i-enable-them). diff --git a/_docs/entities/heat_pump.md b/_docs/entities/heat_pump.md new file mode 100644 index 00000000..fe46f9ea --- /dev/null +++ b/_docs/entities/heat_pump.md @@ -0,0 +1,31 @@ +# Heat Pump + +The following entities are available if you have a heat pump registered against your account. The following heat pumps are known to be compatible + +* Cosy 6 + +## Humidity Sensor + +`sensor.octopus_energy_heat_pump_{{HEAT_PUMP_ID}}_{{SENSOR_CODE}}_humidity` + +This represents the humidity reported by a sensor (e.g. Cosy Pod) that is associated with a heat pump. + +## Temperature Sensor + +`sensor.octopus_energy_heat_pump_{{HEAT_PUMP_ID}}_{{SENSOR_CODE}}_temperature` + +This represents the temperature reported by a sensor (e.g. Cosy Pod) that is associated with a heat pump. + +## Zone + +`climate.octopus_energy_heat_pump_{{HEAT_PUMP_ID}}_{{ZONE_CODE}}` + +This can be used to control the target temperature and mode for a given zone (e.g. water or zone 1) linked to your heat pump. It will also display the current temperature linked to the primary sensor for the zone. + +The following operation modes are available + +* `Heat` - This represents as `on` in the app +* `Off` - This represents as `off` in the app +* `Auto` - This represents as `auto` in the app + +In addition, there is the preset of `boost`, which activates boost mode for the zone for 1 hour. If you require boost to be on for a different amount of time, then you can use the [available service](../services.md#octopus_energyboost_heat_pump_zone). \ No newline at end of file diff --git a/_docs/index.md b/_docs/index.md index 741c2da9..7f170787 100644 --- a/_docs/index.md +++ b/_docs/index.md @@ -14,6 +14,7 @@ Below are the main features of the integration * [Saving sessions support](#octoplus) * [Wheel of fortune support](#wheel-of-fortune) * [Greener days support](#greenness-forecast) +* [Heat Pump support](#heat-pumps) ## How to install @@ -92,7 +93,7 @@ To support the wheel of fortune that is awarded every month to customers. [Full ### Greenness Forecast -To support Octopus Energy's [greener days](https://octopus.energy/smart/greener-days/). [Full list of greenness forecast entites](./entities/greenness_forecast.md). +To support Octopus Energy's [greener days](https://octopus.energy/smart/greener-days/). [Full list of greenness forecast entities](./entities/greenness_forecast.md). ## Target Rate Sensors @@ -114,6 +115,10 @@ These sensors compare the cost of the previous consumption to another tariff to Please follow the [setup guide](./setup/tariff_comparison.md). +### Heat Pumps + +To support heat pumps connected to Octopus Energy, like the [Cosy 6](https://octopus.energy/cosy-heat-pump/). [Full list of heat pump entities](./entities/heat_pump.md). + ## Events This integration raises several events, which can be used for various tasks like automations. For more information, please see the [events docs](./events.md). diff --git a/_docs/services.md b/_docs/services.md index 81087360..7d84e5ca 100644 --- a/_docs/services.md +++ b/_docs/services.md @@ -269,4 +269,15 @@ This automation adds weightings based on the national grids carbon intensity, as {% set ns = namespace(list = []) %} {%- for a in forecast -%} {%- set ns.list = ns.list + [{ "start": a.from.strftime('%Y-%m-%dT%H:%M:%SZ'), "end": a.to.strftime('%Y-%m-%dT%H:%M:%SZ'), "weighting": a.intensity_forecast | float }] -%} {%- endfor -%} {{ ns.list }} -``` \ No newline at end of file +``` + +## octopus_energy.boost_heat_pump_zone + +Allows you to boost a given heat pump zone for a set amount of time. + +| Attribute | Optional | Description | +| ------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------- | +| `target.entity_id` | `no` | The name of the heat pump zone boost mode should be applied to (e.g. `climate.octopus_energy_heat_pump_{{HEAT_PUMP_ID}}_{{ZONE_CODE}}`). | +| `data.hours` | `no` | The number of hours to turn boost mode on for. This can be between 0 and 12. | +| `data.minutes` | `no` | The number of minutes to turn boost mode on for. This can be 0, 15, or 45. | +| `data.target_temperature` | `yes` | The optional target temperature to boost to. If not supplied, then the current target temperature will be used. | \ No newline at end of file diff --git a/custom_components/octopus_energy/__init__.py b/custom_components/octopus_energy/__init__.py index 6c14f344..c764f800 100644 --- a/custom_components/octopus_energy/__init__.py +++ b/custom_components/octopus_energy/__init__.py @@ -22,17 +22,22 @@ from .statistics import get_statistic_ids_to_remove from .intelligent import get_intelligent_features, is_intelligent_product, mock_intelligent_device from .config.rolling_target_rates import async_migrate_rolling_target_config +from .coordinators.heat_pump_configuration_and_status import HeatPumpCoordinatorResult, async_setup_heat_pump_coordinator from .config.main import async_migrate_main_config from .config.target_rates import async_migrate_target_config from .config.cost_tracker import async_migrate_cost_tracker_config from .utils import get_active_tariff -from .utils.debug_overrides import DebugOverride, async_get_debug_override +from .utils.debug_overrides import MeterDebugOverride, async_get_account_debug_override, async_get_meter_debug_override from .utils.error import api_exception_to_string from .storage.account import async_load_cached_account, async_save_cached_account from .storage.intelligent_device import async_load_cached_intelligent_device, async_save_cached_intelligent_device from .storage.rate_weightings import async_load_cached_rate_weightings + +from .heat_pump import get_mock_heat_pump_id, mock_heat_pump_status_and_configuration +from .storage.heat_pump import async_load_cached_heat_pump, async_save_cached_heat_pump + from .const import ( CONFIG_FAVOUR_DIRECT_DEBIT_RATES, CONFIG_KIND, @@ -45,6 +50,7 @@ CONFIG_MAIN_HOME_PRO_API_KEY, CONFIG_MAIN_OLD_API_KEY, CONFIG_VERSION, + DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_KEY, DATA_CUSTOM_RATE_WEIGHTINGS_KEY, DATA_HOME_PRO_CLIENT, DATA_INTELLIGENT_DEVICE, @@ -67,7 +73,7 @@ REPAIR_UNKNOWN_INTELLIGENT_PROVIDER ) -ACCOUNT_PLATFORMS = ["sensor", "binary_sensor", "number", "switch", "text", "time", "event", "select"] +ACCOUNT_PLATFORMS = ["sensor", "binary_sensor", "number", "switch", "text", "time", "event", "select", "climate"] TARGET_RATE_PLATFORMS = ["binary_sensor"] COST_TRACKER_PLATFORMS = ["sensor"] TARIFF_COMPARISON_PLATFORMS = ["sensor"] @@ -347,7 +353,7 @@ async def async_setup_dependencies(hass, config): has_intelligent_tariff = False intelligent_mpan = None intelligent_serial_number = None - debug_override: DebugOverride | None = None + account_debug_override = await async_get_account_debug_override(hass, account_id) for point in account_info["electricity_meter_points"]: mpan = point["mpan"] electricity_tariff = get_active_tariff(now, point["agreements"]) @@ -359,8 +365,6 @@ async def async_setup_dependencies(hass, config): for meter in point["meters"]: serial_number = meter["serial_number"] - - debug_override = await async_get_debug_override(hass, mpan, serial_number) if electricity_tariff is not None: if meter["is_export"] == False: @@ -374,7 +378,7 @@ async def async_setup_dependencies(hass, config): if electricity_device is not None: device_registry.async_remove_device(electricity_device.id) - should_mock_intelligent_data = debug_override.mock_intelligent_controls if debug_override is not None else False + should_mock_intelligent_data = account_debug_override.mock_intelligent_controls if account_debug_override is not None else False if should_mock_intelligent_data: # Pick the first meter if we're mocking our intelligent data for point in account_info["electricity_meter_points"]: @@ -435,16 +439,38 @@ 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"] - override = await async_get_debug_override(hass, mpan, serial_number) + override = await async_get_meter_debug_override(hass, mpan, serial_number) tariff_override = override.tariff if override is not None else None planned_dispatches_supported = intelligent_features.planned_dispatches_supported if intelligent_features 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) + mock_heat_pump = account_debug_override.mock_heat_pump if account_debug_override is not None else False + if mock_heat_pump: + heat_pump_id = get_mock_heat_pump_id() + await async_setup_heat_pump_coordinator(hass, account_id, heat_pump_id, True) + + key = DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_KEY.format(heat_pump_id) + try: + hass.data[DOMAIN][account_id][key] = HeatPumpCoordinatorResult(now, 1, heat_pump_id, mock_heat_pump_status_and_configuration()) + await async_save_cached_heat_pump(hass, account_id, heat_pump_id, hass.data[DOMAIN][account_id][key].data) + except: + hass.data[DOMAIN][account_id][key] = HeatPumpCoordinatorResult(now, 1, heat_pump_id, await async_load_cached_heat_pump(hass, account_id, heat_pump_id)) + elif "heat_pump_ids" in account_info: + for heat_pump_id in account_info["heat_pump_ids"]: + await async_setup_heat_pump_coordinator(hass, account_id, heat_pump_id, False) + + key = DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_KEY.format(heat_pump_id) + try: + hass.data[DOMAIN][account_id][key] = HeatPumpCoordinatorResult(now, 1, heat_pump_id, await client.async_get_heat_pump_configuration_and_status(account_id, heat_pump_id)) + await async_save_cached_heat_pump(hass, account_id, heat_pump_id, hass.data[DOMAIN][account_id][key].data) + except: + hass.data[DOMAIN][account_id][key] = HeatPumpCoordinatorResult(now, 1, heat_pump_id, await async_load_cached_heat_pump(hass, account_id, heat_pump_id)) + await async_setup_account_info_coordinator(hass, account_id) - await async_setup_intelligent_dispatches_coordinator(hass, account_id, debug_override.mock_intelligent_controls if debug_override is not None else False) + await async_setup_intelligent_dispatches_coordinator(hass, account_id, account_debug_override.mock_intelligent_controls if account_debug_override is not None else False) - await async_setup_intelligent_settings_coordinator(hass, account_id, intelligent_device.id if intelligent_device is not None else None, debug_override.mock_intelligent_controls if debug_override is not None else False) + await async_setup_intelligent_settings_coordinator(hass, account_id, intelligent_device.id if intelligent_device is not None else None, account_debug_override.mock_intelligent_controls if account_debug_override is not None else False) await async_setup_saving_sessions_coordinators(hass, account_id) @@ -518,22 +544,5 @@ def purge_invalid_external_statistic_ids(call): hass.services.register(DOMAIN, "purge_invalid_external_statistic_ids", purge_invalid_external_statistic_ids) - async def diagnose_heatpump_apis(call): - """Handle the service call.""" - - account_id = None - 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] - - if account_id is None: - raise Exception("Failed to find account id") - - client: OctopusEnergyApiClient = hass.data[DOMAIN][account_id][DATA_CLIENT] - - return await client.async_diagnose_heatpump_apis(account_id) - - hass.services.register(DOMAIN, "diagnose_heatpump_apis", diagnose_heatpump_apis, supports_response=SupportsResponse.ONLY) - # Return boolean to indicate that initialization was successful. return True \ No newline at end of file diff --git a/custom_components/octopus_energy/api_client/__init__.py b/custom_components/octopus_energy/api_client/__init__.py index 3c01af56..001ba07d 100644 --- a/custom_components/octopus_energy/api_client/__init__.py +++ b/custom_components/octopus_energy/api_client/__init__.py @@ -21,6 +21,7 @@ from .wheel_of_fortune import WheelOfFortuneSpinsResponse from .greenness_forecast import GreennessForecast from .free_electricity_sessions import FreeElectricitySession, FreeElectricitySessionsResponse +from .heat_pump import HeatPumpResponse _LOGGER = logging.getLogger(__name__) @@ -34,6 +35,7 @@ octoplusAccountInfo(accountNumber: "{account_id}") {{ isOctoplusEnrolled }} + octoHeatPumpControllerEuids(accountNumber: "{account_id}") account(accountNumber: "{account_id}") {{ electricityAgreements(active: true) {{ meterPoint {{ @@ -347,23 +349,43 @@ }} ''' -diagnose_heatpump_apis_query = '''query {{ - octoHeatPumpControllerEuids(accountNumber: "{account_id}") - heatPumpStatus(accountNumber: "{account_id}") {{ - isConnected - climateControlStatus {{ - climateControlEnabled - targetClimateControlTemperature - currentClimateControlTemperature - }} - waterTemperatureStatus {{ - waterTemperatureEnabled - currentWaterTemperature - }} +heat_pump_set_zone_mode_without_setpoint_mutation = ''' +mutation {{ + octoHeatPumpSetZoneMode(accountNumber: "{account_id}", euid: "{euid}", operationParameters: {{ + zone: {zone_id}, + mode: {zone_mode} + }}) {{ + transactionId }} -}}''' +}} +''' + +heat_pump_set_zone_mode_with_setpoint_mutation = ''' +mutation {{ + octoHeatPumpSetZoneMode(accountNumber: "{account_id}", euid: "{euid}", operationParameters: {{ + zone: {zone_id}, + mode: {zone_mode}, + setpointInCelsius: "{target_temperature}" + }}) {{ + transactionId + }} +}} +''' -diagnose_heatpump_apis_secondary_query = ''' +heat_pump_boost_zone_mutation = ''' +mutation {{ + octoHeatPumpSetZoneMode(accountNumber: "{account_id}", euid: "{euid}", operationParameters: {{ + zone: {zone_id}, + mode: BOOST, + setpointInCelsius: "{target_temperature}", + endAt: "{end_at}" + }}) {{ + transactionId + }} +}} +''' + +heat_pump_status_and_config_query = ''' query {{ octoHeatPumpControllerStatus(accountNumber: "{account_id}", euid: "{euid}") {{ sensors {{ @@ -389,45 +411,11 @@ }} }} }} - heatPumpControllerConfiguration(accountNumber: "{account_id}", euid: "{euid}") {{ + octoHeatPumpControllerConfiguration(accountNumber: "{account_id}", euid: "{euid}") {{ controller {{ state + heatPumpTimezone connected - lastReset - }} - zones {{ - configuration {{ - code - zoneType - enabled - displayName - primarySensor - currentOperation {{ - mode - setpointInCelsius - action - end - }} - callForHeat - heatDemand - emergency - sensors {{ - ... on ADCSensorConfiguration {{ - code - displayName - type - enabled - }} - ... on ZigbeeSensorConfiguration {{ - code - displayName - type - id - firmwareVersion - boostEnabled - }} - }} - }} }} heatPump {{ serialNumber @@ -476,13 +464,6 @@ }} }} }} - }} - octoHeatPumpControllerConfiguration(accountNumber: "{account_id}", euid: "{euid}") {{ - controller {{ - state - heatPumpTimezone - connected - }} zones {{ configuration {{ code @@ -510,7 +491,6 @@ code displayName type - id firmwareVersion boostEnabled }} @@ -790,6 +770,7 @@ async def async_get_account(self, 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, + "heat_pump_ids": account_response_body["data"]["octoHeatPumpControllerEuids"] if "data" in account_response_body and "octoHeatPumpControllerEuids" in account_response_body["data"] else [], "electricity_meter_points": list(map(self.map_electricity_meters, account_response_body["data"]["account"]["electricityAgreements"] if "electricityAgreements" in account_response_body["data"]["account"] and account_response_body["data"]["account"]["electricityAgreements"] is not None @@ -810,33 +791,58 @@ async def async_get_account(self, account_id): return None - async def async_diagnose_heatpump_apis(self, account_id): - """Diagnose the heatpump apis""" + async def async_get_heat_pump_configuration_and_status(self, account_id: str, euid: str): + """Get a heat pump configuration and status""" await self.async_refresh_token() try: client = self._create_client_session() url = f'{self._base_url}/v1/graphql/' - payload = { "query": diagnose_heatpump_apis_query.format(account_id=account_id) } + payload = { "query": heat_pump_status_and_config_query.format(account_id=account_id, euid=euid) } headers = { "Authorization": f"JWT {self._graphql_token}" } - async with client.post(url, json=payload, headers=headers) as greenness_forecast_response: + async with client.post(url, json=payload, headers=headers) as heat_pump_response: + response = await self.__async_read_response__(heat_pump_response, url) + + if (response is not None and "data" in response and "octoHeatPumpControllerConfiguration" in response["data"] and "octoHeatPumpControllerStatus" in response["data"]): + return HeatPumpResponse.parse_obj(response["data"]) + + return None + + except TimeoutError: + _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') + raise TimeoutException() + + async def async_set_heat_pump_zone_mode(self, account_id: str, euid: str, zone_id: str, zone_mode: str, target_temperature: float | None): + """Sets the mode for a given heat pump zone""" + await self.async_refresh_token() - original_response = await self.__async_read_response__(greenness_forecast_response, url, True) - euids = original_response["data"]["octoHeatPumpControllerEuids"] if "data" in original_response and "octoHeatPumpControllerEuids" in original_response["data"] else [] - euid_responses = [] - if euids is not None: - for euid in euids: - client = self._create_client_session() - url = f'{self._base_url}/v1/graphql/' - payload = { "query": diagnose_heatpump_apis_secondary_query.format(account_id=account_id, euid=euid) } - headers = { "Authorization": f"JWT {self._graphql_token}" } - async with client.post(url, json=payload, headers=headers) as greenness_forecast_response: - euid_responses.append(await self.__async_read_response__(greenness_forecast_response, url, True)) - - return { - "main": original_response, - "euids": euid_responses - } + try: + client = self._create_client_session() + url = f'{self._base_url}/v1/graphql/' + query = (heat_pump_set_zone_mode_with_setpoint_mutation.format(account_id=account_id, euid=euid, zone_id=zone_id, zone_mode=zone_mode, target_temperature=target_temperature) + if target_temperature is not None + else heat_pump_set_zone_mode_without_setpoint_mutation.format(account_id=account_id, euid=euid, zone_id=zone_id, zone_mode=zone_mode)) + payload = { "query": query } + headers = { "Authorization": f"JWT {self._graphql_token}" } + async with client.post(url, json=payload, headers=headers) as heat_pump_response: + await self.__async_read_response__(heat_pump_response, url) + + except TimeoutError: + _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') + raise TimeoutException() + + async def async_boost_heat_pump_zone(self, account_id: str, euid: str, zone_id: str, end_datetime: datetime, target_temperature: float): + """Boost a given heat pump zone""" + await self.async_refresh_token() + + try: + client = self._create_client_session() + url = f'{self._base_url}/v1/graphql/' + query = heat_pump_boost_zone_mutation.format(account_id=account_id, euid=euid, zone_id=zone_id, end_at=end_datetime.isoformat(sep="T"), target_temperature=target_temperature) + payload = { "query": query } + headers = { "Authorization": f"JWT {self._graphql_token}" } + async with client.post(url, json=payload, headers=headers) as heat_pump_response: + await self.__async_read_response__(heat_pump_response, url) except TimeoutError: _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') diff --git a/custom_components/octopus_energy/api_client/heat_pump.py b/custom_components/octopus_energy/api_client/heat_pump.py new file mode 100644 index 00000000..3f1c5859 --- /dev/null +++ b/custom_components/octopus_energy/api_client/heat_pump.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from typing import Any, List, Optional + +from pydantic import BaseModel + + +class Connectivity(BaseModel): + online: bool + retrievedAt: str + + +class Telemetry(BaseModel): + temperatureInCelsius: float + humidityPercentage: Optional[float] + retrievedAt: str + + +class Sensor(BaseModel): + code: str + connectivity: Connectivity + telemetry: Telemetry + + +class ZoneTelemetry(BaseModel): + setpointInCelsius: float + mode: str + relaySwitchedOn: bool + heatDemand: bool + retrievedAt: str + + +class Zone(BaseModel): + zone: str + telemetry: ZoneTelemetry + + +class OctoHeatPumpControllerStatus(BaseModel): + sensors: List[Sensor] + zones: List[Zone] + + +class Controller(BaseModel): + state: List[str] + heatPumpTimezone: str + connected: bool + + +class ValueAndUnit(BaseModel): + value: str + unit: str + + +class AllowableRange(BaseModel): + minimum: ValueAndUnit + maximum: ValueAndUnit + + +class HeatingFlowTemperature(BaseModel): + currentTemperature: ValueAndUnit + allowableRange: AllowableRange + + +class AllowableRange(BaseModel): + minimum: ValueAndUnit + maximum: ValueAndUnit + + +class WeatherCompensation(BaseModel): + enabled: bool + allowableRange: AllowableRange + currentRange: AllowableRange + + +class HeatPump(BaseModel): + serialNumber: Any + model: str + hardwareVersion: str + faultCodes: List + maxWaterSetpoint: int + minWaterSetpoint: int + heatingFlowTemperature: HeatingFlowTemperature + weatherCompensation: WeatherCompensation + + +class CurrentOperation(BaseModel): + mode: str + setpointInCelsius: Optional[float] + action: Optional[str] + end: str + + +class SensorConfiguration(BaseModel): + code: str + displayName: str + type: str + enabled: Optional[bool] = None + firmwareVersion: Optional[str] = None + boostEnabled: Optional[bool] = None + + +class Configuration(BaseModel): + code: str + zoneType: str + enabled: bool + displayName: str + primarySensor: Optional[str] + currentOperation: CurrentOperation + callForHeat: bool + heatDemand: bool + emergency: bool + sensors: List[SensorConfiguration] + + +class ConfigurationZone(BaseModel): + configuration: Configuration + + +class OctoHeatPumpControllerConfiguration(BaseModel): + controller: Controller + heatPump: HeatPump + zones: List[ConfigurationZone] + + +class HeatPumpResponse(BaseModel): + octoHeatPumpControllerStatus: OctoHeatPumpControllerStatus + octoHeatPumpControllerConfiguration: OctoHeatPumpControllerConfiguration diff --git a/custom_components/octopus_energy/climate.py b/custom_components/octopus_energy/climate.py new file mode 100644 index 00000000..786388e9 --- /dev/null +++ b/custom_components/octopus_energy/climate.py @@ -0,0 +1,103 @@ +import logging + +import voluptuous as vol + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_platform +import homeassistant.helpers.config_validation as cv + +from .api_client.heat_pump import HeatPumpResponse +from .heat_pump import get_mock_heat_pump_id +from .heat_pump.zone import OctopusEnergyHeatPumpZone +from .utils.debug_overrides import async_get_account_debug_override + +from .const import ( + CONFIG_ACCOUNT_ID, + DATA_ACCOUNT, + DATA_CLIENT, + DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_COORDINATOR, + DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_KEY, + DOMAIN, + + CONFIG_MAIN_API_KEY +) +from .api_client import OctopusEnergyApiClient + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass, entry, async_add_entities): + """Setup sensors based on our entry""" + + config = dict(entry.data) + + if entry.options: + config.update(entry.options) + + if CONFIG_MAIN_API_KEY in config: + await async_setup_default_sensors(hass, config, async_add_entities) + + return True + +async def async_setup_default_sensors(hass, config, async_add_entities): + _LOGGER.debug('Setting up default sensors') + + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + "boost_heat_pump_zone", + vol.All( + cv.make_entity_service_schema( + { + vol.Required("hours"): cv.positive_int, + vol.Required("minutes"): cv.positive_int, + vol.Optional("target_temperature"): cv.positive_float, + }, + extra=vol.ALLOW_EXTRA, + ), + ), + "async_boost_heat_pump_zone" + ) + + entities = [] + + account_id = config[CONFIG_ACCOUNT_ID] + client = hass.data[DOMAIN][account_id][DATA_CLIENT] + account_debug_override = await async_get_account_debug_override(hass, account_id) + account_result = hass.data[DOMAIN][account_id][DATA_ACCOUNT] + account_info = account_result.account if account_result is not None else None + + mock_heat_pump = account_debug_override.mock_heat_pump if account_debug_override is not None else False + if mock_heat_pump: + heat_pump_id = get_mock_heat_pump_id() + key = DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_KEY.format(heat_pump_id) + coordinator = hass.data[DOMAIN][account_id][DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_COORDINATOR.format(heat_pump_id)] + entities.extend(setup_heat_pump_sensors(hass, client, account_id, heat_pump_id, hass.data[DOMAIN][account_id][key].data, coordinator, mock_heat_pump)) + elif "heat_pump_ids" in account_info: + for heat_pump_id in account_info["heat_pump_ids"]: + key = DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_KEY.format(heat_pump_id) + coordinator = hass.data[DOMAIN][account_id][DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_COORDINATOR.format(heat_pump_id)] + entities.extend(setup_heat_pump_sensors(hass, client, account_id, heat_pump_id, hass.data[DOMAIN][account_id][key].data, coordinator, mock_heat_pump)) + + async_add_entities(entities) + +def setup_heat_pump_sensors(hass: HomeAssistant, client: OctopusEnergyApiClient, account_id: str, heat_pump_id: str, heat_pump_response: HeatPumpResponse, coordinator, mock_heat_pump: bool): + + entities = [] + + if heat_pump_response is not None and heat_pump_response.octoHeatPumpControllerConfiguration is not None: + for zone in heat_pump_response.octoHeatPumpControllerConfiguration.zones: + if zone.configuration is not None: + if zone.configuration.enabled == False: + continue + + entities.append(OctopusEnergyHeatPumpZone( + hass, + coordinator, + client, + account_id, + heat_pump_id, + heat_pump_response.octoHeatPumpControllerConfiguration.heatPump, + zone, + mock_heat_pump + )) + + return entities \ No newline at end of file diff --git a/custom_components/octopus_energy/const.py b/custom_components/octopus_energy/const.py index c175b719..b1c1b368 100644 --- a/custom_components/octopus_energy/const.py +++ b/custom_components/octopus_energy/const.py @@ -16,6 +16,7 @@ REFRESH_RATE_IN_MINUTES_OCTOPLUS_POINTS = 60 REFRESH_RATE_IN_MINUTES_GREENNESS_FORECAST = 180 REFRESH_RATE_IN_MINUTES_HOME_PRO_CONSUMPTION = 0.17 +REFRESH_RATE_IN_MINUTES_HEAT_PUMP = 1 CONFIG_VERSION = 5 @@ -141,12 +142,16 @@ DATA_FREE_ELECTRICITY_SESSIONS_COORDINATOR = "FREE_ELECTRICITY_SESSIONS_COORDINATOR" DATA_CUSTOM_RATE_WEIGHTINGS_KEY = "DATA_CUSTOM_RATE_WEIGHTINGS_{}" +DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_KEY = "HEAT_PUMP_CONFIGURATION_AND_STATUS_{}" +DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_COORDINATOR = "HEAT_PUMP_CONFIGURATION_AND_STATUS_COORDINATOR_{}" + 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" STORAGE_METER_DEBUG_OVERRIDE_NAME = "octopus_energy.{}-{}-override.json" +STORAGE_ACCOUNT_DEBUG_OVERRIDE_NAME = "octopus_energy.{}-override.json" INTELLIGENT_SOURCE_SMART_CHARGE = "smart-charge" INTELLIGENT_SOURCE_BUMP_CHARGE = "bump-charge" diff --git a/custom_components/octopus_energy/coordinators/heat_pump_configuration_and_status.py b/custom_components/octopus_energy/coordinators/heat_pump_configuration_and_status.py new file mode 100644 index 00000000..63051ec0 --- /dev/null +++ b/custom_components/octopus_energy/coordinators/heat_pump_configuration_and_status.py @@ -0,0 +1,124 @@ +import logging +from datetime import datetime, timedelta + +from homeassistant.util.dt import (utcnow) +from homeassistant.helpers.update_coordinator import ( + DataUpdateCoordinator +) + +from ..const import ( + COORDINATOR_REFRESH_IN_SECONDS, + DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_COORDINATOR, + DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_KEY, + DOMAIN, + + DATA_CLIENT, + DATA_ACCOUNT, + DATA_ACCOUNT_COORDINATOR, + REFRESH_RATE_IN_MINUTES_HEAT_PUMP, +) + +from ..api_client import ApiException, OctopusEnergyApiClient +from ..api_client.heat_pump import HeatPumpResponse +from . import BaseCoordinatorResult + +from ..heat_pump import mock_heat_pump_status_and_configuration + +_LOGGER = logging.getLogger(__name__) + +class HeatPumpCoordinatorResult(BaseCoordinatorResult): + euid: str + data: HeatPumpResponse + + def __init__(self, last_evaluated: datetime, request_attempts: int, euid: str, data: HeatPumpResponse, last_error: Exception | None = None): + super().__init__(last_evaluated, request_attempts, REFRESH_RATE_IN_MINUTES_HEAT_PUMP, None, last_error) + self.euid = euid + self.data = data + +async def async_refresh_heat_pump_configuration_and_status( + current: datetime, + client: OctopusEnergyApiClient, + account_info, + euid: str, + existing_heat_pump_result: HeatPumpCoordinatorResult | None, + is_mocked: bool +): + if (account_info is not None): + account_id = account_info["id"] + if (existing_heat_pump_result is None or current >= existing_heat_pump_result.next_refresh): + status_and_configuration = None + raised_exception = None + + if is_mocked: + status_and_configuration = mock_heat_pump_status_and_configuration() + elif euid is not None: + try: + status_and_configuration = await client.async_get_heat_pump_configuration_and_status(account_id, euid) + _LOGGER.debug(f'Heat Pump config and status retrieved for account {account_id} and device {euid}') + except Exception as e: + if isinstance(e, ApiException) == False: + raise + + raised_exception = e + _LOGGER.debug(f'Failed to retrieve heat pump configuration and status for account {account_id} and device {euid}') + + if status_and_configuration is not None: + return HeatPumpCoordinatorResult(current, 1, euid, status_and_configuration) + + result = None + if (existing_heat_pump_result is not None): + result = HeatPumpCoordinatorResult( + existing_heat_pump_result.last_evaluated, + existing_heat_pump_result.request_attempts + 1, + euid, + existing_heat_pump_result.data, + last_error=raised_exception + ) + + if (result.request_attempts == 2): + _LOGGER.warning(f"Failed to retrieve new heat pump configuration and status - using cached settings. See diagnostics sensor for more information.") + else: + # We want to force into our fallback mode + result = HeatPumpCoordinatorResult(current - timedelta(minutes=REFRESH_RATE_IN_MINUTES_HEAT_PUMP), 2, euid, None, last_error=raised_exception) + _LOGGER.warning(f"Failed to retrieve new heat pump configuration and status. See diagnostics sensor for more information.") + + return result + + return existing_heat_pump_result + +async def async_setup_heat_pump_coordinator(hass, account_id: str, euid: str, mock_heat_pump_data: bool): + key = DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_KEY.format(euid) + # Reset data as we might have new information + hass.data[DOMAIN][account_id][key] = None + + async def async_update_heat_pump_data(): + """Fetch data from API endpoint.""" + # Request our account data to be refreshed + account_coordinator = hass.data[DOMAIN][account_id][DATA_ACCOUNT_COORDINATOR] + if account_coordinator is not None: + await account_coordinator.async_request_refresh() + + current = utcnow() + client: OctopusEnergyApiClient = hass.data[DOMAIN][account_id][DATA_CLIENT] + account_result = hass.data[DOMAIN][account_id][DATA_ACCOUNT] + account_info = account_result.account if account_result is not None else None + + hass.data[DOMAIN][account_id][key] = await async_refresh_heat_pump_configuration_and_status( + current, + client, + account_info, + euid, + hass.data[DOMAIN][account_id][key] if key in hass.data[DOMAIN][account_id] else None, + mock_heat_pump_data + ) + + return hass.data[DOMAIN][account_id][key] + + hass.data[DOMAIN][account_id][DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_COORDINATOR.format(euid)] = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"heat_pump_{account_id}", + update_method=async_update_heat_pump_data, + update_interval=timedelta(seconds=COORDINATOR_REFRESH_IN_SECONDS), + always_update=True + ) \ No newline at end of file diff --git a/custom_components/octopus_energy/coordinators/intelligent_dispatches.py b/custom_components/octopus_energy/coordinators/intelligent_dispatches.py index e4084a19..d94509fa 100644 --- a/custom_components/octopus_energy/coordinators/intelligent_dispatches.py +++ b/custom_components/octopus_energy/coordinators/intelligent_dispatches.py @@ -79,7 +79,7 @@ async def async_refresh_intelligent_dispatches( raise raised_exception=e - _LOGGER.debug('Failed to retrieve intelligent dispatches for account {account_id}') + _LOGGER.debug(f'Failed to retrieve intelligent dispatches for account {account_id}') if is_data_mocked: dispatches = mock_intelligent_dispatches() diff --git a/custom_components/octopus_energy/coordinators/intelligent_settings.py b/custom_components/octopus_energy/coordinators/intelligent_settings.py index c6a58edd..5c5ec7bc 100644 --- a/custom_components/octopus_energy/coordinators/intelligent_settings.py +++ b/custom_components/octopus_energy/coordinators/intelligent_settings.py @@ -56,7 +56,7 @@ async def async_refresh_intelligent_settings( raise raised_exception = e - _LOGGER.debug('Failed to retrieve intelligent settings for account {account_id}') + _LOGGER.debug(f'Failed to retrieve intelligent settings for account {account_id}') if is_settings_mocked: settings = mock_intelligent_settings() diff --git a/custom_components/octopus_energy/diagnostics.py b/custom_components/octopus_energy/diagnostics.py index 4115996f..999428c2 100644 --- a/custom_components/octopus_energy/diagnostics.py +++ b/custom_components/octopus_energy/diagnostics.py @@ -16,6 +16,8 @@ DATA_CLIENT ) from .api_client import OctopusEnergyApiClient, TimeoutException +from .heat_pump import get_mock_heat_pump_id, mock_heat_pump_status_and_configuration +from .utils.debug_overrides import AccountDebugOverride, async_get_account_debug_override _LOGGER = logging.getLogger(__name__) @@ -43,7 +45,7 @@ async def async_get_device_consumption_data(client: OctopusEnergyApiClient, devi except Exception as e: return f"Failed to retrieve - {e}" -async def async_get_diagnostics(client: OctopusEnergyApiClient, account_id: str, existing_account_info: dict, get_entity_info: Callable[[dict], dict]): +async def async_get_diagnostics(client: OctopusEnergyApiClient, account_id: str, existing_account_info: dict, account_debug_override: AccountDebugOverride | None, get_entity_info: Callable[[dict], dict]): _LOGGER.info('Retrieving account details for diagnostics...') if existing_account_info is None: @@ -118,11 +120,26 @@ async def async_get_diagnostics(client: OctopusEnergyApiClient, account_id: str, account_info = async_redact_data(account_info, { "id" }) if account_info is not None else None + mock_heat_pump = account_debug_override.mock_heat_pump if account_debug_override is not None else False + + heat_pumps = {} + if mock_heat_pump: + heat_pump_id = get_mock_heat_pump_id() + heat_pumps[heat_pump_id] = mock_heat_pump_status_and_configuration().dict() + elif "heat_pump_ids" in account_info: + for heat_pump_id in account_info["heat_pump_ids"]: + try: + heat_pump = await client.async_get_heat_pump_configuration_and_status(account_id, heat_pump_id) + heat_pumps[heat_pump_id] = heat_pump.dict() if heat_pump is not None else "Not found" + except Exception as e: + heat_pumps[heat_pump_id] = f"Failed to retrieve - {e}" + return { "account": account_info, "entities": get_entity_info(redacted_mappings), "intelligent_device": intelligent_device.to_dict() if intelligent_device is not None else None, - "intelligent_settings": intelligent_settings.to_dict() if intelligent_settings is not None else None + "intelligent_settings": intelligent_settings.to_dict() if intelligent_settings is not None else None, + "heat_pumps": heat_pumps, } async def async_get_device_diagnostics(hass, entry, device): @@ -137,6 +154,7 @@ async def async_get_device_diagnostics(hass, entry, device): account_result = hass.data[DOMAIN][account_id][DATA_ACCOUNT] account_info = account_result.account if account_result is not None else None client: OctopusEnergyApiClient = hass.data[DOMAIN][account_id][DATA_CLIENT] + account_debug_override = await async_get_account_debug_override(hass, account_id) def get_entity_info(redacted_mappings): entity_registry = er.async_get(hass) @@ -163,7 +181,7 @@ def get_entity_info(redacted_mappings): return entity_info - return await async_get_diagnostics(client, account_id, account_info, get_entity_info) + return await async_get_diagnostics(client, account_id, account_info, account_debug_override, get_entity_info) async def async_get_config_entry_diagnostics(hass, entry): """Return diagnostics for a device.""" @@ -177,6 +195,7 @@ async def async_get_config_entry_diagnostics(hass, entry): account_result = hass.data[DOMAIN][account_id][DATA_ACCOUNT] account_info = account_result.account if account_result is not None else None client: OctopusEnergyApiClient = hass.data[DOMAIN][account_id][DATA_CLIENT] + account_debug_override = await async_get_account_debug_override(hass, account_id) def get_entity_info(redacted_mappings): entity_registry = er.async_get(hass) @@ -203,4 +222,4 @@ def get_entity_info(redacted_mappings): return entity_info - return await async_get_diagnostics(client, account_id, account_info, get_entity_info) \ No newline at end of file + return await async_get_diagnostics(client, account_id, account_info, account_debug_override, get_entity_info) \ No newline at end of file diff --git a/custom_components/octopus_energy/diagnostics_entities/heat_pump_data_last_retrieved.py b/custom_components/octopus_energy/diagnostics_entities/heat_pump_data_last_retrieved.py new file mode 100644 index 00000000..d2cae3f1 --- /dev/null +++ b/custom_components/octopus_energy/diagnostics_entities/heat_pump_data_last_retrieved.py @@ -0,0 +1,20 @@ +from .base import OctopusEnergyBaseDataLastRetrieved + +class OctopusEnergyHeatPumpDataLastRetrieved(OctopusEnergyBaseDataLastRetrieved): + """Sensor for displaying the last time the heat pump data was last retrieved.""" + + def __init__(self, hass, coordinator, account_id: str, heat_pump_id: str): + """Init sensor.""" + self._account_id = account_id + self._heat_pump_id = heat_pump_id + OctopusEnergyBaseDataLastRetrieved.__init__(self, hass, coordinator) + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_{self._heat_pump_id}_heat_pump_data_last_retrieved" + + @property + def name(self): + """Name of the sensor.""" + return f"Heat Pump Data Last Retrieved ({self._heat_pump_id}/{self._account_id})" \ No newline at end of file diff --git a/custom_components/octopus_energy/heat_pump/__init__.py b/custom_components/octopus_energy/heat_pump/__init__.py new file mode 100644 index 00000000..246d2200 --- /dev/null +++ b/custom_components/octopus_energy/heat_pump/__init__.py @@ -0,0 +1,350 @@ +from datetime import timedelta +import random + +from homeassistant.util.dt import (utcnow) + +from ..api_client.heat_pump import HeatPumpResponse + +def get_mock_heat_pump_id(): + return "ABC" + +def mock_heat_pump_status_and_configuration(): + now = utcnow() + data = { + "octoHeatPumpControllerStatus": { + "sensors": [ + { + "code": "ADC1", + "connectivity": { + "online": True, + "retrievedAt": (now - timedelta(seconds=random.randrange(1, 120))).strftime("%Y-%m-%dT%H:%M:%S.%f%z") + }, + "telemetry": { + "temperatureInCelsius": 57 + (random.randrange(1, 20) * 0.1), + "humidityPercentage": None, + "retrievedAt": (now - timedelta(seconds=random.randrange(1, 120))).strftime("%Y-%m-%dT%H:%M:%S.%f%z") + } + }, + { + "code": "ADC2", + "connectivity": { + "online": True, + "retrievedAt": (now - timedelta(seconds=random.randrange(1, 120))).strftime("%Y-%m-%dT%H:%M:%S.%f%z") + }, + "telemetry": { + "temperatureInCelsius": -273 + (random.randrange(1, 20) * 0.1), + "humidityPercentage": None, + "retrievedAt": (now - timedelta(seconds=random.randrange(1, 120))).strftime("%Y-%m-%dT%H:%M:%S.%f%z") + } + }, + { + "code": "ADC3", + "connectivity": { + "online": True, + "retrievedAt": (now - timedelta(seconds=random.randrange(1, 120))).strftime("%Y-%m-%dT%H:%M:%S.%f%z") + }, + "telemetry": { + "temperatureInCelsius": -273 + (random.randrange(1, 20) * 0.1), + "humidityPercentage": None, + "retrievedAt": (now - timedelta(seconds=random.randrange(1, 120))).strftime("%Y-%m-%dT%H:%M:%S.%f%z") + } + }, + { + "code": "ADC4", + "connectivity": { + "online": True, + "retrievedAt": (now - timedelta(seconds=random.randrange(1, 120))).strftime("%Y-%m-%dT%H:%M:%S.%f%z") + }, + "telemetry": { + "temperatureInCelsius": -273 + (random.randrange(1, 20) * 0.1), + "humidityPercentage": None, + "retrievedAt": (now - timedelta(seconds=random.randrange(1, 120))).strftime("%Y-%m-%dT%H:%M:%S.%f%z") + } + }, + { + "code": "SENSOR01", + "connectivity": { + "online": True, + "retrievedAt": (now - timedelta(seconds=random.randrange(1, 120))).strftime("%Y-%m-%dT%H:%M:%S.%f%z") + }, + "telemetry": { + "temperatureInCelsius": 18 + (random.randrange(1, 20) * 0.1), + "humidityPercentage": 57 + (random.randrange(1, 20) * 0.1), + "retrievedAt": (now - timedelta(seconds=random.randrange(1, 120))).strftime("%Y-%m-%dT%H:%M:%S.%f%z") + } + }, + { + "code": "SENSOR02", + "connectivity": { + "online": True, + "retrievedAt": "2024-12-01T10:04:54.955000+00:00" + }, + "telemetry": { + "temperatureInCelsius": 22 + (random.randrange(1, 20) * 0.1), + "humidityPercentage": 54 + (random.randrange(1, 20) * 0.1), + "retrievedAt": (now - timedelta(seconds=random.randrange(1, 120))).strftime("%Y-%m-%dT%H:%M:%S.%f%z") + } + }, + { + "code": "SENSOR03", + "connectivity": { + "online": True, + "retrievedAt": (now - timedelta(seconds=random.randrange(1, 120))).strftime("%Y-%m-%dT%H:%M:%S.%f%z") + }, + "telemetry": { + "temperatureInCelsius": 22 + (random.randrange(1, 20) * 0.1), + "humidityPercentage": 60 + (random.randrange(1, 20) * 0.1), + "retrievedAt": (now - timedelta(seconds=random.randrange(1, 120))).strftime("%Y-%m-%dT%H:%M:%S.%f%z") + } + }, + { + "code": "SENSOR04", + "connectivity": { + "online": True, + "retrievedAt": (now - timedelta(seconds=random.randrange(1, 120))).strftime("%Y-%m-%dT%H:%M:%S.%f%z") + }, + "telemetry": { + "temperatureInCelsius": 22 + (random.randrange(1, 20) * 0.1), + "humidityPercentage": 46 + (random.randrange(1, 20) * 0.1), + "retrievedAt": (now - timedelta(seconds=random.randrange(1, 120))).strftime("%Y-%m-%dT%H:%M:%S.%f%z") + } + } + ], + "zones": [ + { + "zone": "WATER", + "telemetry": { + "setpointInCelsius": -300, + "mode": "AUTO", + "relaySwitchedOn": False, + "heatDemand": False, + "retrievedAt": (now - timedelta(seconds=random.randrange(1, 120))).strftime("%Y-%m-%dT%H:%M:%S.%f%z") + } + }, + { + "zone": "ZONE_1", + "telemetry": { + "setpointInCelsius": 22, + "mode": "AUTO", + "relaySwitchedOn": False, + "heatDemand": False, + "retrievedAt": (now - timedelta(seconds=random.randrange(1, 120))).strftime("%Y-%m-%dT%H:%M:%S.%f%z") + } + }, + { + "zone": "ZONE_2", + "telemetry": { + "setpointInCelsius": 7, + "mode": "OFF", + "relaySwitchedOn": False, + "heatDemand": False, + "retrievedAt": (now - timedelta(seconds=random.randrange(1, 120))).strftime("%Y-%m-%dT%H:%M:%S.%f%z") + } + }, + { + "zone": "AUXILIARY", + "telemetry": { + "setpointInCelsius": 7, + "mode": "OFF", + "relaySwitchedOn": False, + "heatDemand": False, + "retrievedAt": (now - timedelta(seconds=random.randrange(1, 120))).strftime("%Y-%m-%dT%H:%M:%S.%f%z") + } + } + ] + }, + "octoHeatPumpControllerConfiguration": { + "controller": { + "state": [ + "NORMAL_MODE" + ], + "heatPumpTimezone": "GMT0BST,M3.5.0/1,M10.5.0", + "connected": True + }, + "heatPump": { + "serialNumber": "ABC", + "model": "Cosy 6", + "hardwareVersion": "v5.1.0", + "faultCodes": [], + "maxWaterSetpoint": 60, + "minWaterSetpoint": 40, + "heatingFlowTemperature": { + "currentTemperature": { + "value": "56", + "unit": "DEGREES_CELSIUS" + }, + "allowableRange": { + "minimum": { + "value": "30", + "unit": "DEGREES_CELSIUS" + }, + "maximum": { + "value": "70", + "unit": "DEGREES_CELSIUS" + } + } + }, + "weatherCompensation": { + "enabled": True, + "allowableRange": { + "minimum": { + "value": "30", + "unit": "DEGREES_CELSIUS" + }, + "maximum": { + "value": "70", + "unit": "DEGREES_CELSIUS" + } + }, + "currentRange": { + "minimum": { + "value": "45", + "unit": "DEGREES_CELSIUS" + }, + "maximum": { + "value": "55", + "unit": "DEGREES_CELSIUS" + } + } + } + }, + "zones": [ + { + "configuration": { + "code": "WATER", + "zoneType": "WATER", + "enabled": True, + "displayName": "WATER", + "primarySensor": "ADC1", + "currentOperation": { + "mode": "AUTO", + "setpointInCelsius": None, + "action": "OFF", + "end": "1970-01-01T00:00:00+00:00" + }, + "callForHeat": False, + "heatDemand": False, + "emergency": False, + "sensors": [ + { + "code": "ADC1", + "displayName": "ADC1", + "type": "NTC", + "enabled": True + }, + { + "code": "ADC2", + "displayName": "ADC2", + "type": "NTC", + "enabled": True + }, + { + "code": "ADC3", + "displayName": "ADC3", + "type": "NTC", + "enabled": True + }, + { + "code": "ADC4", + "displayName": "ADC4", + "type": "NTC", + "enabled": True + } + ] + } + }, + { + "configuration": { + "code": "ZONE_1", + "zoneType": "HEAT", + "enabled": True, + "displayName": "ZONE1", + "primarySensor": "SENSOR03", + "currentOperation": { + "mode": "AUTO", + "setpointInCelsius": 22, + "action": None, + "end": "1970-01-01T00:00:00+00:00" + }, + "callForHeat": False, + "heatDemand": False, + "emergency": False, + "sensors": [ + { + "code": "SENSOR01", + "displayName": "Hallway", + "type": "ZIGBEE", + "id": None, + "firmwareVersion": "0D", + "boostEnabled": True + }, + { + "code": "SENSOR02", + "displayName": "bedoom", + "type": "ZIGBEE", + "id": None, + "firmwareVersion": "0D", + "boostEnabled": True + }, + { + "code": "SENSOR03", + "displayName": "Mom and Dad", + "type": "ZIGBEE", + "id": None, + "firmwareVersion": "0D", + "boostEnabled": True + }, + { + "code": "SENSOR04", + "displayName": "Lounge ", + "type": "ZIGBEE", + "id": None, + "firmwareVersion": "0D", + "boostEnabled": True + } + ] + } + }, + { + "configuration": { + "code": "ZONE_2", + "zoneType": "HEAT", + "enabled": False, + "displayName": "ZONE2", + "primarySensor": None, + "currentOperation": { + "mode": "OFF", + "setpointInCelsius": 7, + "action": None, + "end": "1970-01-01T00:00:00+00:00" + }, + "callForHeat": False, + "heatDemand": False, + "emergency": True, + "sensors": [] + } + }, + { + "configuration": { + "code": "AUXILIARY", + "zoneType": "AUX", + "enabled": False, + "displayName": "AUX", + "primarySensor": None, + "currentOperation": { + "mode": "OFF", + "setpointInCelsius": 7, + "action": None, + "end": "1970-01-01T00:00:00+00:00" + }, + "callForHeat": False, + "heatDemand": False, + "emergency": False, + "sensors": [] + } + } + ] + } + } + + return HeatPumpResponse.parse_obj(data) \ No newline at end of file diff --git a/custom_components/octopus_energy/heat_pump/base.py b/custom_components/octopus_energy/heat_pump/base.py new file mode 100644 index 00000000..5d221fb1 --- /dev/null +++ b/custom_components/octopus_energy/heat_pump/base.py @@ -0,0 +1,56 @@ +from homeassistant.core import HomeAssistant + +from homeassistant.helpers.entity import generate_entity_id, DeviceInfo + +from ..const import ( + DOMAIN, +) +from ..api_client.heat_pump import HeatPump, SensorConfiguration + +class BaseOctopusEnergyHeatPumpSensor: + _unrecorded_attributes = frozenset({"data_last_retrieved"}) + + def __init__(self, hass: HomeAssistant, heat_pump_id: str, heat_pump: HeatPump, entity_domain = "sensor"): + """Init sensor""" + self._heat_pump = heat_pump + self._heat_pump_id = heat_pump_id + + self._attributes = { + } + + self.entity_id = generate_entity_id(entity_domain + ".{}", self.unique_id, hass=hass) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"heat_pump_{heat_pump.serialNumber}")}, + name=f"Heat Pump ({heat_pump.serialNumber})", + connections=set(), + manufacturer="Octopus" if heat_pump.model is not None and "cosy" in heat_pump.model.lower() else None, + model=heat_pump.model, + hw_version=heat_pump.hardwareVersion + ) + +class BaseOctopusEnergyHeatPumpSensorSensor(BaseOctopusEnergyHeatPumpSensor): + _unrecorded_attributes = frozenset({"data_last_retrieved"}) + + def __init__(self, hass: HomeAssistant, heat_pump_id: str, heat_pump: HeatPump, sensor: SensorConfiguration, entity_domain = "sensor"): + """Init sensor""" + self._heat_pump = heat_pump + self._heat_pump_id = heat_pump_id + self._sensor = sensor + + self._attributes = { + "type": sensor.type, + "code": sensor.code + } + + self.entity_id = generate_entity_id(entity_domain + ".{}", self.unique_id, hass=hass) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"heat_pump_sensor_{heat_pump.serialNumber}_{sensor.code}")}, + name=f"Heat Pump Sensor ({sensor.code})", + connections=set(), + manufacturer="Octopus" if heat_pump.model is not None and "cosy" in heat_pump.model.lower() else None, + model=heat_pump.model, + sw_version=sensor.firmwareVersion, + hw_version=sensor.type + ) \ No newline at end of file diff --git a/custom_components/octopus_energy/heat_pump/sensor_humidity.py b/custom_components/octopus_energy/heat_pump/sensor_humidity.py new file mode 100644 index 00000000..08f25990 --- /dev/null +++ b/custom_components/octopus_energy/heat_pump/sensor_humidity.py @@ -0,0 +1,116 @@ +from datetime import datetime +import logging +from typing import List + +from homeassistant.const import ( + STATE_UNAVAILABLE, + STATE_UNKNOWN +) +from homeassistant.core import HomeAssistant, callback + +from homeassistant.util.dt import (now) +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity +) +from homeassistant.components.sensor import ( + RestoreSensor, + SensorDeviceClass, + SensorStateClass, +) + +from .base import (BaseOctopusEnergyHeatPumpSensorSensor) +from ..utils.attributes import dict_to_typed_dict +from ..api_client.heat_pump import HeatPump, Sensor, SensorConfiguration +from ..coordinators.heat_pump_configuration_and_status import HeatPumpCoordinatorResult + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyHeatPumpSensorHumidity(CoordinatorEntity, BaseOctopusEnergyHeatPumpSensorSensor, RestoreSensor): + """Sensor for displaying the humidity of a heat pump sensor.""" + + def __init__(self, hass: HomeAssistant, coordinator, heat_pump_id: str, heat_pump: HeatPump, sensor: SensorConfiguration): + """Init sensor.""" + # Pass coordinator to base class + CoordinatorEntity.__init__(self, coordinator) + BaseOctopusEnergyHeatPumpSensorSensor.__init__(self, hass, heat_pump_id, heat_pump, sensor) + + self._state = None + self._last_updated = None + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_heat_pump_{self._heat_pump_id}_{self._sensor.code}_humidity" + + @property + def name(self): + """Name of the sensor.""" + return f"Humidity ({self._sensor.displayName}) Heat Pump ({self._heat_pump_id})" + + @property + def state_class(self): + """The state class of sensor""" + return SensorStateClass.MEASUREMENT + + @property + def device_class(self): + """The type of sensor""" + return SensorDeviceClass.HUMIDITY + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:thermometer" + + @property + def native_unit_of_measurement(self): + """Unit of measurement of the sensor.""" + return "%" + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def native_value(self): + return self._state + + @callback + 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 = now() + result: HeatPumpCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None + if (result is not None and + result.data is not None and + result.data.octoHeatPumpControllerStatus is not None and + result.data.octoHeatPumpControllerStatus.sensors): + _LOGGER.debug(f"Updating OctopusEnergyHeatPumpSensorHumidity for '{self._heat_pump_id}/{self._sensor.code}'") + + self._state = None + sensors: List[Sensor] = result.data.octoHeatPumpControllerStatus.sensors + for sensor in sensors: + if sensor.code == self._sensor.code and sensor.telemetry is not None: + self._state = sensor.telemetry.humidityPercentage + self._attributes["retrieved_at"] = datetime.fromisoformat(sensor.telemetry.retrievedAt) + + self._last_updated = current + + self._attributes = dict_to_typed_dict(self._attributes) + super()._handle_coordinator_update() + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None and self._state is None: + self._state = None if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) else state.state + self._attributes = dict_to_typed_dict(state.attributes, []) + + self._attributes["type"] = self._sensor.type + self._attributes["code"] = self._sensor.code + + _LOGGER.debug(f'Restored OctopusEnergyHeatPumpSensorHumidity state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/heat_pump/sensor_temperature.py b/custom_components/octopus_energy/heat_pump/sensor_temperature.py new file mode 100644 index 00000000..1134fac4 --- /dev/null +++ b/custom_components/octopus_energy/heat_pump/sensor_temperature.py @@ -0,0 +1,117 @@ +from datetime import datetime +import logging +from typing import List + +from homeassistant.const import ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfTemperature +) +from homeassistant.core import HomeAssistant, callback + +from homeassistant.util.dt import (now) +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity +) +from homeassistant.components.sensor import ( + RestoreSensor, + SensorDeviceClass, + SensorStateClass, +) + +from .base import (BaseOctopusEnergyHeatPumpSensorSensor) +from ..utils.attributes import dict_to_typed_dict +from ..api_client.heat_pump import HeatPump, Sensor, SensorConfiguration +from ..coordinators.heat_pump_configuration_and_status import HeatPumpCoordinatorResult + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyHeatPumpSensorTemperature(CoordinatorEntity, BaseOctopusEnergyHeatPumpSensorSensor, RestoreSensor): + """Sensor for displaying the temperature of a heat pump sensor.""" + + def __init__(self, hass: HomeAssistant, coordinator, heat_pump_id: str, heat_pump: HeatPump, sensor: SensorConfiguration): + """Init sensor.""" + # Pass coordinator to base class + CoordinatorEntity.__init__(self, coordinator) + BaseOctopusEnergyHeatPumpSensorSensor.__init__(self, hass, heat_pump_id, heat_pump, sensor) + + self._state = None + self._last_updated = None + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_heat_pump_{self._heat_pump_id}_{self._sensor.code}_temperature" + + @property + def name(self): + """Name of the sensor.""" + return f"Temperature ({self._sensor.displayName}) Heat Pump ({self._heat_pump_id})" + + @property + def state_class(self): + """The state class of sensor""" + return SensorStateClass.MEASUREMENT + + @property + def device_class(self): + """The type of sensor""" + return SensorDeviceClass.TEMPERATURE + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:thermometer" + + @property + def native_unit_of_measurement(self): + """Unit of measurement of the sensor.""" + return UnitOfTemperature.CELSIUS + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def native_value(self): + return self._state + + @callback + 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 = now() + result: HeatPumpCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None + if (result is not None and + result.data is not None and + result.data.octoHeatPumpControllerStatus is not None and + result.data.octoHeatPumpControllerStatus.sensors): + _LOGGER.debug(f"Updating OctopusEnergyHeatPumpSensorTemperature for '{self._heat_pump_id}/{self._sensor.code}'") + + self._state = None + sensors: List[Sensor] = result.data.octoHeatPumpControllerStatus.sensors + for sensor in sensors: + if sensor.code == self._sensor.code and sensor.telemetry is not None: + self._state = sensor.telemetry.temperatureInCelsius + self._attributes["retrieved_at"] = datetime.fromisoformat(sensor.telemetry.retrievedAt) + + self._last_updated = current + + self._attributes = dict_to_typed_dict(self._attributes) + super()._handle_coordinator_update() + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None and self._state is None: + self._state = None if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) else state.state + self._attributes = dict_to_typed_dict(state.attributes, []) + + self._attributes["type"] = self._sensor.type + self._attributes["code"] = self._sensor.code + + _LOGGER.debug(f'Restored OctopusEnergyHeatPumpSensorTemperature state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/heat_pump/zone.py b/custom_components/octopus_energy/heat_pump/zone.py new file mode 100644 index 00000000..55152e1c --- /dev/null +++ b/custom_components/octopus_energy/heat_pump/zone.py @@ -0,0 +1,246 @@ +from datetime import datetime, timedelta +import logging +from typing import List + +from custom_components.octopus_energy.const import DOMAIN +from homeassistant.util.dt import (utcnow) +from homeassistant.exceptions import ServiceValidationError + +from homeassistant.const import ( + UnitOfTemperature, + PRECISION_HALVES, + PRECISION_TENTHS, + ATTR_TEMPERATURE +) +from homeassistant.core import HomeAssistant, callback + +from homeassistant.util.dt import (now) +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity +) +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, + PRESET_NONE, + PRESET_BOOST, +) + +from .base import (BaseOctopusEnergyHeatPumpSensor) +from ..utils.attributes import dict_to_typed_dict +from ..api_client.heat_pump import ConfigurationZone, HeatPump, Sensor, Zone +from ..coordinators.heat_pump_configuration_and_status import HeatPumpCoordinatorResult +from ..api_client import OctopusEnergyApiClient + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyHeatPumpZone(CoordinatorEntity, BaseOctopusEnergyHeatPumpSensor, ClimateEntity): + """Sensor for interacting with a heat pump zone.""" + + _attr_supported_features = ( + ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + ) + + _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF, HVACMode.AUTO] + _attr_hvac_mode = None + _attr_preset_modes = [PRESET_NONE, PRESET_BOOST] + _attr_preset_mode = None + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_target_temperature_step = PRECISION_TENTHS + _attr_target_temperature_step = PRECISION_HALVES + + def __init__(self, hass: HomeAssistant, coordinator, client: OctopusEnergyApiClient, account_id: str, heat_pump_id: str, heat_pump: HeatPump, zone: ConfigurationZone, is_mocked: bool): + """Init sensor.""" + self._zone = zone + self._account_id = account_id + self._client = client + self._is_mocked = is_mocked + self._end_timestamp = None + + if zone.configuration.zoneType == "HEAT": + self._attr_min_temp = 7 + self._attr_max_temp = 30 + else: + self._attr_min_temp = 40 + self._attr_max_temp = 60 + + # Pass coordinator to base class + CoordinatorEntity.__init__(self, coordinator) + BaseOctopusEnergyHeatPumpSensor.__init__(self, hass, heat_pump_id, heat_pump, "climate") + + self._state = None + self._last_updated = None + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_heat_pump_{self._heat_pump_id}_{self._zone.configuration.code}" + + @property + def name(self): + """Name of the sensor.""" + return f"Zone ({self._zone.configuration.displayName}) Heat Pump ({self._heat_pump_id})" + + @callback + def _handle_coordinator_update(self) -> None: + """Retrieve the previous rate.""" + + current = now() + result: HeatPumpCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None + if (result is not None and + result.data is not None and + result.data.octoHeatPumpControllerConfiguration is not None and + result.data.octoHeatPumpControllerConfiguration.zones and + (self._last_updated is None or self._last_updated < result.last_retrieved)): + _LOGGER.debug(f"Updating OctopusEnergyHeatPumpZone for '{self._heat_pump_id}/{self._zone.configuration.code}'") + + zones: List[ConfigurationZone] = result.data.octoHeatPumpControllerConfiguration.zones + for zone in zones: + if zone.configuration is not None and zone.configuration.code == self._zone.configuration.code and zone.configuration.currentOperation is not None: + + if zone.configuration.currentOperation.mode == "ON": + self._attr_hvac_mode = HVACMode.HEAT + self._attr_preset_mode = PRESET_NONE + elif zone.configuration.currentOperation.mode == "OFF": + self._attr_hvac_mode = HVACMode.OFF + self._attr_preset_mode = PRESET_NONE + elif zone.configuration.currentOperation.mode == "AUTO": + self._attr_hvac_mode = HVACMode.AUTO + self._attr_preset_mode = PRESET_NONE + elif zone.configuration.currentOperation.mode == "BOOST": + self._attr_hvac_mode = HVACMode.AUTO + self._attr_preset_mode = PRESET_BOOST + else: + raise Exception(f"Unexpected heat pump mode detected: {zone.configuration.currentOperation.mode}") + + self._attr_target_temperature = zone.configuration.currentOperation.setpointInCelsius + self._end_timestamp = datetime.fromisoformat(zone.configuration.currentOperation.end) if zone.configuration.currentOperation.end is not None else None + + if (result.data.octoHeatPumpControllerStatus.sensors and self._zone.configuration.primarySensor): + sensors: List[Sensor] = result.data.octoHeatPumpControllerStatus.sensors + for sensor in sensors: + if sensor.code == self._zone.configuration.primarySensor and sensor.telemetry is not None: + self._attr_current_temperature = sensor.telemetry.temperatureInCelsius + + self._last_updated = current + + self._attributes = dict_to_typed_dict(self._attributes) + super()._handle_coordinator_update() + + async def async_set_hvac_mode(self, hvac_mode): + """Set new target hvac mode.""" + try: + self._attr_hvac_mode = hvac_mode + self._attr_preset_mode = PRESET_NONE + zone_mode = self.get_zone_mode() + await self._client.async_set_heat_pump_zone_mode(self._account_id, self._heat_pump_id, self._zone.configuration.code, zone_mode, self._attr_target_temperature) + except Exception as e: + if self._is_mocked: + _LOGGER.warning(f'Suppress async_set_hvac_mode error due to mocking mode: {e}') + else: + raise + + self.async_write_ha_state() + + async def async_turn_on(self): + """Turn the entity on.""" + try: + self._attr_hvac_mode = HVACMode.HEAT + self._attr_preset_mode = PRESET_NONE + await self._client.async_set_heat_pump_zone_mode(self._account_id, self._heat_pump_id, self._zone.configuration.code, 'ON', self._attr_target_temperature) + except Exception as e: + if self._is_mocked: + _LOGGER.warning(f'Suppress async_turn_on error due to mocking mode: {e}') + else: + raise + + self.async_write_ha_state() + + async def async_turn_off(self): + """Turn the entity off.""" + try: + self._attr_hvac_mode = HVACMode.OFF + self._attr_preset_mode = PRESET_NONE + await self._client.async_set_heat_pump_zone_mode(self._account_id, self._heat_pump_id, self._zone.configuration.code, 'OFF', None) + except Exception as e: + if self._is_mocked: + _LOGGER.warning(f'Suppress async_turn_off error due to mocking mode: {e}') + else: + raise + + self.async_write_ha_state() + + async def async_set_preset_mode(self, preset_mode): + """Set new target preset mode.""" + try: + self._attr_preset_mode = preset_mode + + if self._attr_preset_mode == PRESET_BOOST: + self._end_timestamp = utcnow() + self._end_timestamp += timedelta(hours=1) + await self._client.async_boost_heat_pump_zone(self._account_id, self._heat_pump_id, self._zone.configuration.code, self._end_timestamp, self._attr_target_temperature) + else: + zone_mode = self.get_zone_mode() + await self._client.async_set_heat_pump_zone_mode(self._account_id, self._heat_pump_id, self._zone.configuration.code, zone_mode, self._attr_target_temperature) + except Exception as e: + if self._is_mocked: + _LOGGER.warning(f'Suppress async_set_preset_mode error due to mocking mode: {e}') + else: + raise + + self.async_write_ha_state() + + async def async_set_temperature(self, **kwargs) -> None: + """Set new target temperature.""" + + try: + self._attr_target_temperature = kwargs[ATTR_TEMPERATURE] + if self._attr_preset_mode == PRESET_BOOST: + await self._client.async_boost_heat_pump_zone(self._account_id, self._heat_pump_id, self._zone.configuration.code, self._end_timestamp, self._attr_target_temperature) + else: + zone_mode = self.get_zone_mode() + await self._client.async_set_heat_pump_zone_mode(self._account_id, self._heat_pump_id, self._zone.configuration.code, zone_mode, self._attr_target_temperature) + except Exception as e: + if self._is_mocked: + _LOGGER.warning(f'Suppress async_set_temperature error due to mocking mode: {e}') + else: + raise + + self.async_write_ha_state() + + @callback + async def async_boost_heat_pump_zone(self, hours: int, minutes: int, target_temperature: float | None = None): + """Boost the heat pump zone""" + + if target_temperature is not None: + if target_temperature < self._attr_min_temp or target_temperature > self._attr_max_temp: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_target_temperature", + translation_placeholders={ + "min_temperature": self._attr_min_temp, + "max_temperature": self._attr_max_temp + }, + ) + + self._end_timestamp = utcnow() + self._end_timestamp += timedelta(hours=hours, minutes=minutes) + self._attr_preset_mode = PRESET_BOOST + await self._client.async_boost_heat_pump_zone(self._account_id, self._heat_pump_id, self._zone.configuration.code, self._end_timestamp, target_temperature if target_temperature is not None else self._attr_target_temperature) + + self.async_write_ha_state() + + def get_zone_mode(self): + if self._attr_preset_mode == PRESET_BOOST: + return "BOOST" + elif self._attr_hvac_mode == HVACMode.HEAT: + return "ON" + elif self._attr_hvac_mode == HVACMode.OFF: + return "OFF" + elif self._attr_hvac_mode == HVACMode.AUTO: + return "AUTO" + else: + raise Exception(f"Unexpected heat pump mode detected: {self._attr_hvac_mode}/{self._attr_preset_mode}") \ No newline at end of file diff --git a/custom_components/octopus_energy/icons.json b/custom_components/octopus_energy/icons.json index 16e5f501..4ab4a308 100644 --- a/custom_components/octopus_energy/icons.json +++ b/custom_components/octopus_energy/icons.json @@ -10,6 +10,7 @@ "reset_cost_tracker": "mdi:refresh", "adjust_accumulative_cost_tracker": "mdi:numeric", "adjust_cost_tracker": "mdi:numeric", - "redeem_octoplus_points_into_account_credit": "mdi:currency-gbp" + "redeem_octoplus_points_into_account_credit": "mdi:currency-gbp", + "boost_heat_pump_zone": "mdi:thermometer-plus" } } \ No newline at end of file diff --git a/custom_components/octopus_energy/octoplus/free_electricity_sessions.py b/custom_components/octopus_energy/octoplus/free_electricity_sessions.py index e015b2ac..35504b3b 100644 --- a/custom_components/octopus_energy/octoplus/free_electricity_sessions.py +++ b/custom_components/octopus_energy/octoplus/free_electricity_sessions.py @@ -1,6 +1,5 @@ import logging -from custom_components.octopus_energy.coordinators.free_electricity_sessions import FreeElectricitySessionsCoordinatorResult from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -23,6 +22,7 @@ ) from ..utils.attributes import dict_to_typed_dict +from ..coordinators.free_electricity_sessions import FreeElectricitySessionsCoordinatorResult _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/octopus_energy/sensor.py b/custom_components/octopus_energy/sensor.py index d7d8ad64..fe868003 100644 --- a/custom_components/octopus_energy/sensor.py +++ b/custom_components/octopus_energy/sensor.py @@ -1,4 +1,5 @@ from datetime import timedelta +from custom_components.octopus_energy.diagnostics_entities.heat_pump_data_last_retrieved import OctopusEnergyHeatPumpDataLastRetrieved import voluptuous as vol import logging @@ -56,11 +57,14 @@ from .diagnostics_entities.intelligent_dispatches_data_last_retrieved import OctopusEnergyIntelligentDispatchesDataLastRetrieved from .diagnostics_entities.intelligent_settings_data_last_retrieved import OctopusEnergyIntelligentSettingsDataLastRetrieved from .diagnostics_entities.free_electricity_sessions_data_last_retrieved import OctopusEnergyFreeElectricitySessionsDataLastRetrieved +from .heat_pump import get_mock_heat_pump_id +from .heat_pump.sensor_temperature import OctopusEnergyHeatPumpSensorTemperature +from .heat_pump.sensor_humidity import OctopusEnergyHeatPumpSensorHumidity from .api_client.intelligent_device import IntelligentDevice from .intelligent.current_state import OctopusEnergyIntelligentCurrentState from .intelligent import get_intelligent_features -from .utils.debug_overrides import async_get_debug_override +from .utils.debug_overrides import async_get_account_debug_override, async_get_meter_debug_override from .coordinators.current_consumption import async_create_current_consumption_coordinator from .coordinators.gas_rates import async_setup_gas_rates_coordinator @@ -70,6 +74,9 @@ from .coordinators.wheel_of_fortune import async_setup_wheel_of_fortune_spins_coordinator from .coordinators.current_consumption_home_pro import async_create_home_pro_current_consumption_coordinator +from .api_client.heat_pump import HeatPumpResponse +from .api_client.intelligent_device import IntelligentDevice + from .api_client import OctopusEnergyApiClient 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 @@ -94,6 +101,8 @@ DATA_FREE_ELECTRICITY_SESSIONS_COORDINATOR, DATA_ACCOUNT_COORDINATOR, DATA_GREENNESS_FORECAST_COORDINATOR, + DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_COORDINATOR, + DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_KEY, DATA_HOME_PRO_CLIENT, DATA_INTELLIGENT_DEVICE, DATA_INTELLIGENT_DISPATCHES_COORDINATOR, @@ -322,6 +331,8 @@ async def async_setup_default_sensors(hass: HomeAssistant, config, async_add_ent entities.append(OctopusEnergyOctoplusPoints(hass, client, account_id)) entities.append(OctopusEnergyFreeElectricitySessionsDataLastRetrieved(hass, free_electricity_session_coordinator, account_id)) + account_debug_override = await async_get_account_debug_override(hass, account_id) + now = utcnow() if len(account_info["electricity_meter_points"]) > 0: @@ -349,7 +360,7 @@ async def async_setup_default_sensors(hass: HomeAssistant, config, async_add_ent entities.append(OctopusEnergyCurrentRatesDataLastRetrieved(hass, electricity_rate_coordinator, True, meter, point)) entities.append(OctopusEnergyCurrentStandingChargeDataLastRetrieved(hass, electricity_standing_charges_coordinator, True, meter, point)) - debug_override = await async_get_debug_override(hass, mpan, serial_number) + debug_override = await async_get_meter_debug_override(hass, mpan, serial_number) previous_consumption_coordinator = await async_create_previous_consumption_and_rates_coordinator( hass, account_id, @@ -362,11 +373,11 @@ async def async_setup_default_sensors(hass: HomeAssistant, config, async_add_ent ) entities.append(OctopusEnergyPreviousAccumulativeElectricityConsumption(hass, client, previous_consumption_coordinator, account_id, meter, point)) entities.append(OctopusEnergyPreviousAccumulativeElectricityCost(hass, previous_consumption_coordinator, meter, point)) - entities.append(OctopusEnergySavingSessionBaseline(hass, saving_session_coordinator, previous_consumption_coordinator, meter, point, debug_override.mock_saving_session_baseline if debug_override is not None else False)) + entities.append(OctopusEnergySavingSessionBaseline(hass, saving_session_coordinator, previous_consumption_coordinator, meter, point, account_debug_override.mock_saving_session_baseline if debug_override is not None else False)) entities.append(OctopusEnergyPreviousConsumptionAndRatesDataLastRetrieved(hass, previous_consumption_coordinator, True, meter, point)) if octoplus_enrolled: - entities.append(OctopusEnergyFreeElectricitySessionBaseline(hass, free_electricity_session_coordinator, previous_consumption_coordinator, meter, point, debug_override.mock_saving_session_baseline if debug_override is not None else False)) + entities.append(OctopusEnergyFreeElectricitySessionBaseline(hass, free_electricity_session_coordinator, previous_consumption_coordinator, meter, point, account_debug_override.mock_saving_session_baseline if debug_override is not None else False)) # Create a peak override for each available peak type for our tariff total_unique_rates = await get_unique_electricity_rates(hass, client, electricity_tariff if debug_override is None or debug_override.tariff is None else debug_override.tariff) @@ -456,7 +467,7 @@ async def async_setup_default_sensors(hass: HomeAssistant, config, async_add_ent entities.append(OctopusEnergyCurrentRatesDataLastRetrieved(hass, gas_rate_coordinator, False, meter, point)) entities.append(OctopusEnergyCurrentStandingChargeDataLastRetrieved(hass, gas_standing_charges_coordinator, False, meter, point)) - debug_override = await async_get_debug_override(hass, mprn, serial_number) + debug_override = await async_get_meter_debug_override(hass, mprn, serial_number) previous_consumption_coordinator = await async_create_previous_consumption_and_rates_coordinator( hass, account_id, @@ -524,6 +535,18 @@ async def async_setup_default_sensors(hass: HomeAssistant, config, async_add_ent else: _LOGGER.info('No gas meters available') + mock_heat_pump = account_debug_override.mock_heat_pump if account_debug_override is not None else False + if mock_heat_pump: + heat_pump_id = get_mock_heat_pump_id() + key = DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_KEY.format(heat_pump_id) + coordinator = hass.data[DOMAIN][account_id][DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_COORDINATOR.format(heat_pump_id)] + entities.extend(setup_heat_pump_sensors(hass, account_id, heat_pump_id, hass.data[DOMAIN][account_id][key].data, coordinator)) + elif "heat_pump_ids" in account_info: + for heat_pump_id in account_info["heat_pump_ids"]: + key = DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_KEY.format(heat_pump_id) + coordinator = hass.data[DOMAIN][account_id][DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_COORDINATOR.format(heat_pump_id)] + entities.extend(setup_heat_pump_sensors(hass, account_id, heat_pump_id, hass.data[DOMAIN][account_id][key].data, coordinator)) + # Migrate entity ids that might have changed # for item in entity_ids_to_migrate: # entity_id = registry.async_get_entity_id("sensor", DOMAIN, item["old"]) @@ -536,6 +559,42 @@ async def async_setup_default_sensors(hass: HomeAssistant, config, async_add_ent async_add_entities(entities) +def setup_heat_pump_sensors(hass: HomeAssistant, account_id: str, heat_pump_id: str, heat_pump_response: HeatPumpResponse, coordinator): + + entities = [] + + if coordinator is not None: + entities.append(OctopusEnergyHeatPumpDataLastRetrieved(hass, coordinator, account_id, heat_pump_id)) + + if heat_pump_response is not None and heat_pump_response.octoHeatPumpControllerConfiguration is not None: + for zone in heat_pump_response.octoHeatPumpControllerConfiguration.zones: + if zone.configuration is not None and zone.configuration.sensors is not None: + if zone.configuration.enabled == False: + continue + + for sensor in zone.configuration.sensors: + if sensor.enabled == False: + continue + + entities.append(OctopusEnergyHeatPumpSensorTemperature( + hass, + coordinator, + heat_pump_id, + heat_pump_response.octoHeatPumpControllerConfiguration.heatPump, + sensor + )) + + if sensor.type == "ZIGBEE": + entities.append(OctopusEnergyHeatPumpSensorHumidity( + hass, + coordinator, + heat_pump_id, + heat_pump_response.octoHeatPumpControllerConfiguration.heatPump, + sensor + )) + + return 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] @@ -580,7 +639,7 @@ async def async_setup_cost_sensors(hass: HomeAssistant, entry, config, async_add OctopusEnergyCostTrackerMonthSensor(hass, entry, config, device_entry, sensor_entity_id if sensor_entity_id is not None else sensor.entity_id), ] - debug_override = await async_get_debug_override(hass, mpan, serial_number) + debug_override = await async_get_meter_debug_override(hass, mpan, serial_number) total_unique_rates = await get_unique_electricity_rates(hass, client, tariff if debug_override is None or debug_override.tariff is None else debug_override.tariff) if has_peak_rates(total_unique_rates): for unique_rate_index in range(0, total_unique_rates): diff --git a/custom_components/octopus_energy/services.yaml b/custom_components/octopus_energy/services.yaml index 7809cf70..c0c2655f 100644 --- a/custom_components/octopus_energy/services.yaml +++ b/custom_components/octopus_energy/services.yaml @@ -242,10 +242,6 @@ adjust_cost_tracker: mode: box unit_of_measurement: kWh -diagnose_heatpump_apis: - name: Diagnose heatpump APIs - description: Diagnose available heatpump APIs - register_rate_weightings: name: Register rate weightings description: Registers external weightings against rates, for use with target rate sensors when calculating target periods. @@ -266,4 +262,41 @@ register_rate_weightings: } ] selector: - object: \ No newline at end of file + object: + +boost_heat_pump_zone: + name: Boost heat pump zone + description: Boost a given heat pump zone for a given time period + target: + entity: + integration: octopus_energy + domain: climate + fields: + hours: + name: Hours + description: The number of hours to boost for + required: true + selector: + number: + step: 1 + min: 0 + max: 12 + mode: box + minutes: + name: Minutes + description: The number of minutes to boost for + required: true + selector: + number: + step: 15 + min: 0 + max: 45 + mode: box + target_temperature: + name: Target Temperature + description: The optional target temperature to boost to. If not supplied, then the current target temperature will be used + required: false + selector: + number: + step: 0.5 + mode: box \ No newline at end of file diff --git a/custom_components/octopus_energy/storage/heat_pump.py b/custom_components/octopus_energy/storage/heat_pump.py new file mode 100644 index 00000000..4126b041 --- /dev/null +++ b/custom_components/octopus_energy/storage/heat_pump.py @@ -0,0 +1,23 @@ +import logging +from homeassistant.helpers import storage + +from ..api_client.heat_pump import HeatPumpResponse + +_LOGGER = logging.getLogger(__name__) + +async def async_load_cached_heat_pump(hass, account_id: str, euid: str) -> HeatPumpResponse: + store = storage.Store(hass, "1", f"octopus_energy.{account_id}_{euid}_heat_pump") + + try: + data = await store.async_load() + if data is not None: + _LOGGER.debug(f"Loaded cached intelligent device data for {account_id}/{euid}") + return HeatPumpResponse.parse_obj(data) + except: + return None + +async def async_save_cached_heat_pump(hass, account_id: str, euid: str, heat_pump: HeatPumpResponse): + if heat_pump is not None: + store = storage.Store(hass, "1", f"octopus_energy.{account_id}_{euid}_heat_pump") + await store.async_save(heat_pump.dict()) + _LOGGER.debug(f"Saved heat pymp data for {account_id}/{euid}") \ No newline at end of file diff --git a/custom_components/octopus_energy/storage/intelligent_device.py b/custom_components/octopus_energy/storage/intelligent_device.py index 8c975ff8..5c4ba2f5 100644 --- a/custom_components/octopus_energy/storage/intelligent_device.py +++ b/custom_components/octopus_energy/storage/intelligent_device.py @@ -12,15 +12,15 @@ async def async_load_cached_intelligent_device(hass, account_id: str) -> Intelli data = await store.async_load() if data is not None: _LOGGER.debug(f"Loaded cached intelligent device data for {account_id}") - return IntelligentDevice( - data["id"], - data["provider"], - data["make"], - data["model"], - data["vehicleBatterySizeInKwh"], - data["chargePointPowerInKw"], - data["is_charger"] - ) + return IntelligentDevice( + data["id"], + data["provider"], + data["make"], + data["model"], + data["vehicleBatterySizeInKwh"], + data["chargePointPowerInKw"], + data["is_charger"] + ) except: return None diff --git a/custom_components/octopus_energy/translations/en.json b/custom_components/octopus_energy/translations/en.json index bdd2a422..dff3f0be 100644 --- a/custom_components/octopus_energy/translations/en.json +++ b/custom_components/octopus_energy/translations/en.json @@ -278,6 +278,9 @@ "octoplus_points_maximum_points": { "message": "You cannot redeem more than {redeemable_points} points" }, + "invalid_target_temperature": { + "message": "Temperature must be equal or between {min_temperature} and {max_temperature}" + }, "invalid_rate_weightings": { "message": "{error}" } diff --git a/custom_components/octopus_energy/utils/debug_overrides.py b/custom_components/octopus_energy/utils/debug_overrides.py index eccac6c1..60c25e1e 100644 --- a/custom_components/octopus_energy/utils/debug_overrides.py +++ b/custom_components/octopus_energy/utils/debug_overrides.py @@ -2,32 +2,57 @@ from homeassistant.helpers import storage -from ..const import STORAGE_METER_DEBUG_OVERRIDE_NAME +from ..const import STORAGE_ACCOUNT_DEBUG_OVERRIDE_NAME, STORAGE_METER_DEBUG_OVERRIDE_NAME from ..utils import Tariff _LOGGER = logging.getLogger(__name__) -class DebugOverride(): +class MeterDebugOverride(): - def __init__(self, tariff: Tariff, mock_intelligent_controls: bool, mock_saving_session_baseline: bool): + def __init__(self, tariff: Tariff): self.tariff = tariff + +async def async_get_meter_debug_override(hass, mpan_mprn: str, serial_number: str) -> MeterDebugOverride | None: + storage_key = STORAGE_METER_DEBUG_OVERRIDE_NAME.format(mpan_mprn, serial_number) + store = storage.Store(hass, "1", storage_key) + + try: + data = await store.async_load() + if data is not None: + debug = MeterDebugOverride( + Tariff(data["product_code"], data["tariff_code"]) if "tariff_code" in data and "product_code" in data else None + ) + + _LOGGER.info(f"Debug overrides discovered {mpan_mprn}/{serial_number} - {debug}") + + return debug + + except: + return None + + return None + +class AccountDebugOverride(): + + def __init__(self, mock_intelligent_controls: bool, mock_saving_session_baseline: bool, mock_heat_pump: bool): self.mock_intelligent_controls = mock_intelligent_controls self.mock_saving_session_baseline = mock_saving_session_baseline + self.mock_heat_pump = mock_heat_pump -async def async_get_debug_override(hass, mpan_mprn: str, serial_number: str) -> DebugOverride | None: - storage_key = STORAGE_METER_DEBUG_OVERRIDE_NAME.format(mpan_mprn, serial_number) +async def async_get_account_debug_override(hass, account_id: str) -> AccountDebugOverride | None: + storage_key = STORAGE_ACCOUNT_DEBUG_OVERRIDE_NAME.format(account_id) store = storage.Store(hass, "1", storage_key) try: data = await store.async_load() if data is not None: - debug = DebugOverride( - Tariff(data["product_code"], data["tariff_code"]) if "tariff_code" in data and "product_code" in data else None, + debug = AccountDebugOverride( data["mock_intelligent_controls"] == True if "mock_intelligent_controls" in data else False, data["mock_saving_session_baseline"] == True if "mock_saving_session_baseline" in data else False, + data["mock_heat_pump"] == True if "mock_heat_pump" in data else False, ) - _LOGGER.info(f"Debug overrides discovered {mpan_mprn}/{serial_number} - {debug}") + _LOGGER.info(f"Debug overrides discovered {account_id} - {debug}") return debug diff --git a/mkdocs.yml b/mkdocs.yml index b36da049..c86c22b6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -19,6 +19,7 @@ nav: - Wheel Of Fortune: ./entities/wheel_of_fortune.md - Greenness Forecast: ./entities/greenness_forecast.md - Home Pro: ./entities/home_pro.md + - Heat Pump: ./entities/heat_pump.md - Diagnostics: ./entities/diagnostics.md - services.md - events.md diff --git a/requirements.test.txt b/requirements.test.txt index 7f62827f..8f62ad23 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -3,6 +3,7 @@ pytest-socket pytest-asyncio mock homeassistant +pydantic psutil-home-assistant pydantic sqlalchemy diff --git a/tests/integration/api_client/test_get_account.py b/tests/integration/api_client/test_get_account.py index c900529b..9a1d734e 100644 --- a/tests/integration/api_client/test_get_account.py +++ b/tests/integration/api_client/test_get_account.py @@ -60,6 +60,10 @@ async def test_when_get_account_is_called_then_electricity_and_gas_points_return assert "octoplus_enrolled" in account + assert "heat_pump_ids" in account + assert account["heat_pump_ids"] is not None + assert len(account["heat_pump_ids"]) >= 0 + @pytest.mark.asyncio async def test_when_get_account_is_called_and_not_found_then_exception_is_raised(): # Arrange diff --git a/tests/integration/test_async_get_diagnostics.py b/tests/integration/test_async_get_diagnostics.py index 01f08902..29dda6b9 100644 --- a/tests/integration/test_async_get_diagnostics.py +++ b/tests/integration/test_async_get_diagnostics.py @@ -16,12 +16,16 @@ def assert_meter(meter, expected_serial_number: int): assert meter["device_id"] == "**REDACTED**" assert isinstance(meter["latest_consumption"], datetime) - assert meter["device"] is not None - assert isinstance(meter["device"]["total_consumption"], float) - assert isinstance(meter["device"]["consumption"], float) - assert "demand" in meter["device"] - assert isinstance(meter["device"]["start"], datetime) - assert isinstance(meter["device"]["end"], datetime) + if meter["device"] != "Not available": + if meter["device"]["total_consumption"] is not None: + assert isinstance(meter["device"]["total_consumption"], float) + + if meter["device"]["consumption"] is not None: + assert isinstance(meter["device"]["consumption"], float) + + assert "demand" in meter["device"] + assert isinstance(meter["device"]["start"], datetime) + assert isinstance(meter["device"]["end"], datetime) @pytest.mark.asyncio async def test_when_async_get_diagnostics_called_then_account_info_is_returned(): @@ -42,7 +46,7 @@ def get_entity_info(redacted_mappings): } # Act - data = await async_get_diagnostics(client, account_id, None, get_entity_info) + data = await async_get_diagnostics(client, account_id, None, None, get_entity_info) # Assert assert data is not None @@ -95,6 +99,7 @@ def get_entity_info(redacted_mappings): assert "intelligent_device" in data assert "intelligent_settings" in data + assert "heat_pumps" in data @pytest.mark.asyncio async def test_when_async_get_diagnostics_called_and_account_exists_then_account_info_is_returned(): @@ -116,7 +121,7 @@ def get_entity_info(redacted_mappings): } # Act - data = await async_get_diagnostics(client, account_id, existing_account, get_entity_info) + data = await async_get_diagnostics(client, account_id, existing_account, None, get_entity_info) # Assert assert data is not None @@ -168,4 +173,6 @@ def get_entity_info(redacted_mappings): assert data["entities"]["foo"]["last_changed"] == now assert "intelligent_device" in data - assert "intelligent_settings" in data \ No newline at end of file + assert "intelligent_settings" in data + + assert "heat_pumps" in data diff --git a/tests/unit/api_client/__init__.py b/tests/unit/api_client/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/api_client/test_heat_pump.py b/tests/unit/api_client/test_heat_pump.py new file mode 100644 index 00000000..7db745ec --- /dev/null +++ b/tests/unit/api_client/test_heat_pump.py @@ -0,0 +1,346 @@ +from custom_components.octopus_energy.api_client.heat_pump import HeatPumpResponse + +def test_when_valid_dictionary_returned_then_it_can_be_parsed_into_heat_pump_object(): + # Arrange + data = { + "octoHeatPumpControllerStatus": { + "sensors": [ + { + "code": "ADC1", + "connectivity": { + "online": True, + "retrievedAt": "2024-12-01T10:04:54.952000+00:00" + }, + "telemetry": { + "temperatureInCelsius": 57.4, + "humidityPercentage": None, + "retrievedAt": "2024-12-01T10:04:51.588000+00:00" + } + }, + { + "code": "ADC2", + "connectivity": { + "online": True, + "retrievedAt": "2024-12-01T10:04:54.952000+00:00" + }, + "telemetry": { + "temperatureInCelsius": -273.1, + "humidityPercentage": None, + "retrievedAt": "2024-12-01T10:04:51.588000+00:00" + } + }, + { + "code": "ADC3", + "connectivity": { + "online": True, + "retrievedAt": "2024-12-01T10:04:54.953000+00:00" + }, + "telemetry": { + "temperatureInCelsius": -273.1, + "humidityPercentage": None, + "retrievedAt": "2024-12-01T10:04:51.588000+00:00" + } + }, + { + "code": "ADC4", + "connectivity": { + "online": True, + "retrievedAt": "2024-12-01T10:04:54.953000+00:00" + }, + "telemetry": { + "temperatureInCelsius": -273.1, + "humidityPercentage": None, + "retrievedAt": "2024-12-01T10:04:51.588000+00:00" + } + }, + { + "code": "SENSOR01", + "connectivity": { + "online": True, + "retrievedAt": "2024-12-01T10:04:54.953000+00:00" + }, + "telemetry": { + "temperatureInCelsius": 19.4, + "humidityPercentage": 57, + "retrievedAt": "2024-12-01T10:03:15.615000+00:00" + } + }, + { + "code": "SENSOR02", + "connectivity": { + "online": True, + "retrievedAt": "2024-12-01T10:04:54.955000+00:00" + }, + "telemetry": { + "temperatureInCelsius": 22.4, + "humidityPercentage": 54, + "retrievedAt": "2024-12-01T10:03:54.876000+00:00" + } + }, + { + "code": "SENSOR03", + "connectivity": { + "online": True, + "retrievedAt": "2024-12-01T10:04:54.956000+00:00" + }, + "telemetry": { + "temperatureInCelsius": 22.3, + "humidityPercentage": 60, + "retrievedAt": "2024-12-01T10:04:27.571000+00:00" + } + }, + { + "code": "SENSOR04", + "connectivity": { + "online": True, + "retrievedAt": "2024-12-01T10:04:54.957000+00:00" + }, + "telemetry": { + "temperatureInCelsius": 22.7, + "humidityPercentage": 46, + "retrievedAt": "2024-12-01T10:03:12.376000+00:00" + } + } + ], + "zones": [ + { + "zone": "WATER", + "telemetry": { + "setpointInCelsius": -300, + "mode": "AUTO", + "relaySwitchedOn": False, + "heatDemand": False, + "retrievedAt": "2024-12-01T10:04:59.116000+00:00" + } + }, + { + "zone": "ZONE_1", + "telemetry": { + "setpointInCelsius": 22, + "mode": "AUTO", + "relaySwitchedOn": False, + "heatDemand": False, + "retrievedAt": "2024-12-01T10:04:59.117000+00:00" + } + }, + { + "zone": "ZONE_2", + "telemetry": { + "setpointInCelsius": 7, + "mode": "OFF", + "relaySwitchedOn": False, + "heatDemand": False, + "retrievedAt": "2024-12-01T10:04:59.118000+00:00" + } + }, + { + "zone": "AUXILIARY", + "telemetry": { + "setpointInCelsius": 7, + "mode": "OFF", + "relaySwitchedOn": False, + "heatDemand": False, + "retrievedAt": "2024-12-01T10:04:59.118000+00:00" + } + } + ] + }, + "octoHeatPumpControllerConfiguration": { + "controller": { + "state": [ + "NORMAL_MODE" + ], + "heatPumpTimezone": "GMT0BST,M3.5.0/1,M10.5.0", + "connected": True + }, + "heatPump": { + "serialNumber": None, + "model": "Cosy 6", + "hardwareVersion": "v5.1.0", + "faultCodes": [], + "maxWaterSetpoint": 60, + "minWaterSetpoint": 40, + "heatingFlowTemperature": { + "currentTemperature": { + "value": "70", + "unit": "DEGREES_CELSIUS" + }, + "allowableRange": { + "minimum": { + "value": "30", + "unit": "DEGREES_CELSIUS" + }, + "maximum": { + "value": "70", + "unit": "DEGREES_CELSIUS" + } + } + }, + "weatherCompensation": { + "enabled": True, + "allowableRange": { + "minimum": { + "value": "30", + "unit": "DEGREES_CELSIUS" + }, + "maximum": { + "value": "70", + "unit": "DEGREES_CELSIUS" + } + }, + "currentRange": { + "minimum": { + "value": "45", + "unit": "DEGREES_CELSIUS" + }, + "maximum": { + "value": "55", + "unit": "DEGREES_CELSIUS" + } + } + } + }, + "zones": [ + { + "configuration": { + "code": "WATER", + "zoneType": "WATER", + "enabled": True, + "displayName": "WATER", + "primarySensor": "ADC1", + "currentOperation": { + "mode": "AUTO", + "setpointInCelsius": None, + "action": "OFF", + "end": "1970-01-01T00:00:00+00:00" + }, + "callForHeat": False, + "heatDemand": False, + "emergency": False, + "sensors": [ + { + "code": "ADC1", + "displayName": "ADC1", + "type": "NTC", + "enabled": True + }, + { + "code": "ADC2", + "displayName": "ADC2", + "type": "NTC", + "enabled": True + }, + { + "code": "ADC3", + "displayName": "ADC3", + "type": "NTC", + "enabled": True + }, + { + "code": "ADC4", + "displayName": "ADC4", + "type": "NTC", + "enabled": True + } + ] + } + }, + { + "configuration": { + "code": "ZONE_1", + "zoneType": "HEAT", + "enabled": True, + "displayName": "ZONE1", + "primarySensor": "SENSOR03", + "currentOperation": { + "mode": "AUTO", + "setpointInCelsius": 22, + "action": None, + "end": "1970-01-01T00:00:00+00:00" + }, + "callForHeat": False, + "heatDemand": False, + "emergency": False, + "sensors": [ + { + "code": "SENSOR01", + "displayName": "Hallway", + "type": "ZIGBEE", + "id": None, + "firmwareVersion": "0D", + "boostEnabled": True + }, + { + "code": "SENSOR02", + "displayName": "bedoom", + "type": "ZIGBEE", + "id": None, + "firmwareVersion": "0D", + "boostEnabled": True + }, + { + "code": "SENSOR03", + "displayName": "Mom and Dad", + "type": "ZIGBEE", + "id": None, + "firmwareVersion": "0D", + "boostEnabled": True + }, + { + "code": "SENSOR04", + "displayName": "Lounge ", + "type": "ZIGBEE", + "id": None, + "firmwareVersion": "0D", + "boostEnabled": True + } + ] + } + }, + { + "configuration": { + "code": "ZONE_2", + "zoneType": "HEAT", + "enabled": False, + "displayName": "ZONE2", + "primarySensor": None, + "currentOperation": { + "mode": "OFF", + "setpointInCelsius": 7, + "action": None, + "end": "1970-01-01T00:00:00+00:00" + }, + "callForHeat": False, + "heatDemand": False, + "emergency": True, + "sensors": [] + } + }, + { + "configuration": { + "code": "AUXILIARY", + "zoneType": "AUX", + "enabled": False, + "displayName": "AUX", + "primarySensor": None, + "currentOperation": { + "mode": "OFF", + "setpointInCelsius": 7, + "action": None, + "end": "1970-01-01T00:00:00+00:00" + }, + "callForHeat": False, + "heatDemand": False, + "emergency": False, + "sensors": [] + } + } + ] + } + } + + # Act + result = HeatPumpResponse.parse_obj(data) + + # Assert + assert result is not None \ No newline at end of file From 37b69c842754f3312b0c2e6c0f2b814893d14f71 Mon Sep 17 00:00:00 2001 From: David Kendall Date: Tue, 31 Dec 2024 09:11:02 +0000 Subject: [PATCH 13/18] feat: Added additional lifetime and live sensors for heat pumps (Thanks @lwis) Co-authored-by: Lewis Juggins <873275+lwis@users.noreply.github.com> --- _docs/entities/heat_pump.md | 56 ++++++++- _docs/services.md | 18 ++- .../octopus_energy/api_client/__init__.py | 77 ++++++++++++- .../octopus_energy/api_client/heat_pump.py | 17 +++ custom_components/octopus_energy/climate.py | 15 +++ .../octopus_energy/heat_pump/__init__.py | 28 +++++ .../heat_pump/sensor_humidity.py | 3 +- .../heat_pump/sensor_lifetime_energy_input.py | 108 +++++++++++++++++ .../heat_pump/sensor_lifetime_heat_output.py | 108 +++++++++++++++++ .../heat_pump/sensor_lifetime_scop.py | 93 +++++++++++++++ .../heat_pump/sensor_live_cop.py | 97 ++++++++++++++++ .../heat_pump/sensor_live_heat_output.py | 109 ++++++++++++++++++ .../sensor_live_outdoor_temperature.py | 109 ++++++++++++++++++ .../heat_pump/sensor_live_power_input.py | 109 ++++++++++++++++++ .../heat_pump/sensor_temperature.py | 3 +- .../octopus_energy/heat_pump/zone.py | 39 ++++--- custom_components/octopus_energy/icons.json | 3 +- custom_components/octopus_energy/sensor.py | 63 +++++++++- .../octopus_energy/services.yaml | 45 ++++++++ .../octopus_energy/storage/heat_pump.py | 8 +- 20 files changed, 1082 insertions(+), 26 deletions(-) create mode 100644 custom_components/octopus_energy/heat_pump/sensor_lifetime_energy_input.py create mode 100644 custom_components/octopus_energy/heat_pump/sensor_lifetime_heat_output.py create mode 100644 custom_components/octopus_energy/heat_pump/sensor_lifetime_scop.py create mode 100644 custom_components/octopus_energy/heat_pump/sensor_live_cop.py create mode 100644 custom_components/octopus_energy/heat_pump/sensor_live_heat_output.py create mode 100644 custom_components/octopus_energy/heat_pump/sensor_live_outdoor_temperature.py create mode 100644 custom_components/octopus_energy/heat_pump/sensor_live_power_input.py diff --git a/_docs/entities/heat_pump.md b/_docs/entities/heat_pump.md index fe46f9ea..01bf2aae 100644 --- a/_docs/entities/heat_pump.md +++ b/_docs/entities/heat_pump.md @@ -28,4 +28,58 @@ The following operation modes are available * `Off` - This represents as `off` in the app * `Auto` - This represents as `auto` in the app -In addition, there is the preset of `boost`, which activates boost mode for the zone for 1 hour. If you require boost to be on for a different amount of time, then you can use the [available service](../services.md#octopus_energyboost_heat_pump_zone). \ No newline at end of file +In addition, there is the preset of `boost`, which activates boost mode for the zone for 1 hour. If you require boost to be on for a different amount of time, then you can use the [available service](../services.md#octopus_energyboost_heat_pump_zone). + +## Lifetime Seasonal Coefficient of Performance + +`sensor.octopus_energy_heat_pump_{{HEAT_PUMP_ID}}_lifetime_scop` + +This represents the efficiency by the heat pump since commissioning or last reset. + +## Lifetime Energy Input + +`sensor.octopus_energy_heat_pump_{{HEAT_PUMP_ID}}_lifetime_energy_input` + +This represents the energy/power drawn by the heat pump since commissioning or last reset in kWh. + +## Lifetime Heat Output + +`sensor.octopus_energy_heat_pump_{{HEAT_PUMP_ID}}_lifetime_heat_output` + +This represents the energy/heat supplied by the heat pump since commissioning or last reset in kWh. + +## Live Coefficient of Performance + +`sensor.octopus_energy_heat_pump_{{HEAT_PUMP_ID}}_live_cop` + +This represents the instantaneous efficiency of the heat pump. + +!!! note + As this integration uses cloud polling this will inherently have a delay. + +## Live Power Input + +`sensor.octopus_energy_heat_pump_{{HEAT_PUMP_ID}}_live_power_input` + +This represents the instantaneous energy/power being drawn by the heat pump in kWh. + +!!! note + As this integration uses cloud polling this will inherently have a delay. + +## Live Heat Output + +`sensor.octopus_energy_heat_pump_{{HEAT_PUMP_ID}}_live_heat_output` + +This represents the instantaneous energy/heat being supplied by heat pump in kWh. + +!!! note + As the integration uses cloud polling this will inherently have a delay. + +## Live Outdoor Temp + +`sensor.octopus_energy_heat_pump_{{HEAT_PUMP_ID}}_live_outdoor_temperature` + +This represents the current outdoor temperature as observed by the heat pump. + +!!! note + As the integration uses cloud polling this will inherently have a delay. diff --git a/_docs/services.md b/_docs/services.md index 7d84e5ca..fa0ab103 100644 --- a/_docs/services.md +++ b/_docs/services.md @@ -280,4 +280,20 @@ Allows you to boost a given heat pump zone for a set amount of time. | `target.entity_id` | `no` | The name of the heat pump zone boost mode should be applied to (e.g. `climate.octopus_energy_heat_pump_{{HEAT_PUMP_ID}}_{{ZONE_CODE}}`). | | `data.hours` | `no` | The number of hours to turn boost mode on for. This can be between 0 and 12. | | `data.minutes` | `no` | The number of minutes to turn boost mode on for. This can be 0, 15, or 45. | -| `data.target_temperature` | `yes` | The optional target temperature to boost to. If not supplied, then the current target temperature will be used. | \ No newline at end of file +| `data.target_temperature` | `yes` | The optional target temperature to boost to. If not supplied, then the current target temperature will be used. | + + +## octopus_energy.set_heat_pump_flow_temp_config + +Allows you to set the heat pump configuration for fixed and weather compensated flow temperatures, with the option to select which is active. + +!!! warning + Changing this configuration without a good understanding of heat loss and emitter output can cause cycling, defrosting, or incorrect heat delivery. + +| Attribute | Optional | Description | +| ------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------- | +| `target.entity_id` | `no` | Any climate entity belonging to the heat pump which the configuration should be applied to (e.g. `climate.octopus_energy_heat_pump_{{HEAT_PUMP_ID}}_{{ZONE_CODE}}`). | +| `data.weather_comp_enabled` | `no` | Switches weather compensation on or off. | +| `data.weather_comp_min_temperature` | `no` | Minimum allowable temperature for weather compensation, typically no lower than 30. | +| `data.weather_comp_max_temperature` | `no` | Maximum allowable temperature for weather compensation, typically no higher than 70. | +| `data.fixed_flow_temperature` | `no` | If a fixed flow temperature is enabled this value will be used, typically between 30 and 70. | diff --git a/custom_components/octopus_energy/api_client/__init__.py b/custom_components/octopus_energy/api_client/__init__.py index 001ba07d..7d506e72 100644 --- a/custom_components/octopus_energy/api_client/__init__.py +++ b/custom_components/octopus_energy/api_client/__init__.py @@ -385,6 +385,30 @@ }} ''' +heat_pump_update_flow_temp_config_mutation = ''' +mutation {{ + octoHeatPumpUpdateFlowTemperatureConfiguration( + euid: "{euid}" + flowTemperatureInput: {{ + useWeatherCompensation: {use_weather_comp}, + flowTemperature: { + value: "{fixed_flow_temperature}", + unit: DEGREES_CELSIUS + }}, + weatherCompensationValues: {{ + minimum: {{ + value: "{weather_comp_min_temperature}", + unit: DEGREES_CELSIUS + }, + maximum: {{ + value: "{weather_comp_max_temperature}", + unit: DEGREES_CELSIUS + }} + }} + ) +}} +''' + heat_pump_status_and_config_query = ''' query {{ octoHeatPumpControllerStatus(accountNumber: "{account_id}", euid: "{euid}") {{ @@ -498,9 +522,38 @@ }} }} }} + octoHeatPumpLivePerformance(euid: "{euid}") {{ + coefficientOfPerformance + outdoorTemperature {{ + value + unit + }} + heatOutput {{ + value + unit + }} + powerInput {{ + value + unit + }} + readAt + }} + octoHeatPumpLifetimePerformance(euid: "{euid}") {{ + seasonalCoefficientOfPerformance + heatOutput {{ + value + unit + }} + energyInput {{ + value + unit + }} + readAt + }} }} ''' + user_agent_value = "bottlecapdave-ha-octopus-energy" def get_valid_from(rate): @@ -803,7 +856,12 @@ async def async_get_heat_pump_configuration_and_status(self, account_id: str, eu async with client.post(url, json=payload, headers=headers) as heat_pump_response: response = await self.__async_read_response__(heat_pump_response, url) - if (response is not None and "data" in response and "octoHeatPumpControllerConfiguration" in response["data"] and "octoHeatPumpControllerStatus" in response["data"]): + if (response is not None + and "data" in response + and "octoHeatPumpControllerConfiguration" in response["data"] + and "octoHeatPumpControllerStatus" in response["data"] + and "octoHeatPumpLivePerformance" in response["data"] + and "octoHeatPumpLifetimePerformance" in response["data"]): return HeatPumpResponse.parse_obj(response["data"]) return None @@ -812,6 +870,23 @@ async def async_get_heat_pump_configuration_and_status(self, account_id: str, eu _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') raise TimeoutException() + async def async_set_heat_pump_flow_temp_config(self, euid: str, weather_comp_enabled: bool, weather_comp_min_temperature: float | None, weather_comp_max_temperature: float | None, fixed_flow_temperature: float | None): + """Sets the flow temperature for a given heat pump zone""" + await self.async_refresh_token() + + try: + client = self._create_client_session() + url = f'{self._base_url}/v1/graphql/' + query = heat_pump_update_flow_temp_config_mutation.format(euid=euid, weather_comp_enabled=weather_comp_enabled, weather_comp_min_temperature=weather_comp_min_temperature, weather_comp_max_temperature=weather_comp_max_temperature, fixed_flow_temperature=fixed_flow_temperature) + payload = { "query": query } + headers = { "Authorization": f"JWT {self._graphql_token}" } + async with client.post(url, json=payload, headers=headers) as heat_pump_response: + await self.__async_read_response__(heat_pump_response, url) + + except TimeoutError: + _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') + raise TimeoutException() + async def async_set_heat_pump_zone_mode(self, account_id: str, euid: str, zone_id: str, zone_mode: str, target_temperature: float | None): """Sets the mode for a given heat pump zone""" await self.async_refresh_token() diff --git a/custom_components/octopus_energy/api_client/heat_pump.py b/custom_components/octopus_energy/api_client/heat_pump.py index 3f1c5859..fb7d0662 100644 --- a/custom_components/octopus_energy/api_client/heat_pump.py +++ b/custom_components/octopus_energy/api_client/heat_pump.py @@ -122,6 +122,23 @@ class OctoHeatPumpControllerConfiguration(BaseModel): zones: List[ConfigurationZone] +class OctoHeatPumpLivePerformance(BaseModel): + coefficientOfPerformance: str + heatOutput: ValueAndUnit + powerInput: ValueAndUnit + outdoorTemperature: ValueAndUnit + readAt: str + + +class OctoHeatPumpLifetimePerformance(BaseModel): + seasonalCoefficientOfPerformance: str + heatOutput: ValueAndUnit + energyInput: ValueAndUnit + readAt: str + + class HeatPumpResponse(BaseModel): octoHeatPumpControllerStatus: OctoHeatPumpControllerStatus octoHeatPumpControllerConfiguration: OctoHeatPumpControllerConfiguration + octoHeatPumpLivePerformance: OctoHeatPumpLivePerformance + octoHeatPumpLifetimePerformance: OctoHeatPumpLifetimePerformance diff --git a/custom_components/octopus_energy/climate.py b/custom_components/octopus_energy/climate.py index 786388e9..449f971b 100644 --- a/custom_components/octopus_energy/climate.py +++ b/custom_components/octopus_energy/climate.py @@ -56,6 +56,21 @@ async def async_setup_default_sensors(hass, config, async_add_entities): ), "async_boost_heat_pump_zone" ) + platform.async_register_entity_service( + "set_heat_pump_flow_temp_config", + vol.All( + cv.make_entity_service_schema( + { + vol.Required("weather_comp_enabled"): cv.boolean, + vol.Required("weather_comp_min_temperature"): cv.positive_float, + vol.Required("weather_comp_max_temperature"): cv.positive_float, + vol.Required("fixed_flow_temperature"): cv.positive_float, + }, + extra=vol.ALLOW_EXTRA, + ), + ), + "async_set_heat_pump_flow_temp_config" + ) entities = [] diff --git a/custom_components/octopus_energy/heat_pump/__init__.py b/custom_components/octopus_energy/heat_pump/__init__.py index 246d2200..9398ea2a 100644 --- a/custom_components/octopus_energy/heat_pump/__init__.py +++ b/custom_components/octopus_energy/heat_pump/__init__.py @@ -344,6 +344,34 @@ def mock_heat_pump_status_and_configuration(): } } ] + }, + "octoHeatPumpLifetimePerformance": { + "seasonalCoefficientOfPerformance": str(3 + (random.randrange(1, 9) * 0.1)), + "heatOutput": { + "unit": "KILOWATT_HOUR", + "value": str(100 + (random.randrange(1, 20) * 0.1)) + }, + "energyInput": { + "unit": "KILOWATT_HOUR", + "value": str(20 + (random.randrange(1, 20) * 0.1)) + }, + "readAt": (now - timedelta(seconds=random.randrange(1, 120))).strftime("%Y-%m-%dT%H:%M:%S.%f%z") + }, + "octoHeatPumpLivePerformance": { + "coefficientOfPerformance": str(3 + (random.randrange(1, 20) * 0.1)), + "outdoorTemperature": { + "unit": "DEGREES_CELSIUS", + "value": str(10 + (random.randrange(1, 20) * 0.1)) + }, + "heatOutput": { + "value": str(4 + (random.randrange(1, 9) * 0.1)), + "unit": "KILOWATT" + }, + "powerInput": { + "unit": "KILOWATT", + "value": str(1 + (random.randrange(1, 9) * 0.1)) + }, + "readAt": (now - timedelta(seconds=random.randrange(1, 120))).strftime("%Y-%m-%dT%H:%M:%S.%f%z") } } diff --git a/custom_components/octopus_energy/heat_pump/sensor_humidity.py b/custom_components/octopus_energy/heat_pump/sensor_humidity.py index 08f25990..ea04da7d 100644 --- a/custom_components/octopus_energy/heat_pump/sensor_humidity.py +++ b/custom_components/octopus_energy/heat_pump/sensor_humidity.py @@ -78,8 +78,7 @@ def native_value(self): @callback def _handle_coordinator_update(self) -> None: - """Retrieve the previous rate.""" - # Find the previous rate. We only need to do this every half an hour + """Retrieve the current sensor humidity.""" current = now() result: HeatPumpCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None if (result is not None and diff --git a/custom_components/octopus_energy/heat_pump/sensor_lifetime_energy_input.py b/custom_components/octopus_energy/heat_pump/sensor_lifetime_energy_input.py new file mode 100644 index 00000000..c2135744 --- /dev/null +++ b/custom_components/octopus_energy/heat_pump/sensor_lifetime_energy_input.py @@ -0,0 +1,108 @@ +from datetime import datetime +import logging +from typing import List + +from homeassistant.const import ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfEnergy +) +from homeassistant.core import HomeAssistant, callback + +from homeassistant.util.dt import (now) +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity +) +from homeassistant.components.sensor import ( + RestoreSensor, + SensorDeviceClass, + SensorStateClass, +) + +from .base import (BaseOctopusEnergyHeatPumpSensor) +from ..utils.attributes import dict_to_typed_dict +from ..api_client.heat_pump import HeatPump +from ..coordinators.heat_pump_configuration_and_status import HeatPumpCoordinatorResult + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyHeatPumpSensorLifetimeEnergyInput(CoordinatorEntity, BaseOctopusEnergyHeatPumpSensor, RestoreSensor): + """Sensor for displaying the lifetime energy input of a heat pump.""" + + def __init__(self, hass: HomeAssistant, coordinator, heat_pump_id: str, heat_pump: HeatPump): + """Init sensor.""" + # Pass coordinator to base class + CoordinatorEntity.__init__(self, coordinator) + BaseOctopusEnergyHeatPumpSensor.__init__(self, hass, heat_pump_id, heat_pump) + + self._state = None + self._last_updated = None + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_heat_pump_{self._heat_pump_id}_lifetime_energy_input" + + @property + def name(self): + """Name of the sensor.""" + return f"Lifetime Energy Input Heat Pump ({self._heat_pump_id})" + + @property + def state_class(self): + """The state class of sensor""" + return SensorStateClass.TOTAL_INCREASING + + @property + def device_class(self): + """The type of sensor""" + return SensorDeviceClass.ENERGY + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:flash" + + @property + def native_unit_of_measurement(self): + """Unit of measurement of the sensor.""" + return UnitOfEnergy.KILO_WATT_HOUR + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def native_value(self): + return self._state + + @callback + def _handle_coordinator_update(self) -> None: + """Retrieve the lifetime energy input for the heat pump.""" + current = now() + result: HeatPumpCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None + + if (result is not None + and result.data is not None + and result.data.octoHeatPumpLifetimePerformance is not None): + _LOGGER.debug(f"Updating OctopusEnergyHeatPumpSensorLifetimeEnergyInput for '{self._heat_pump_id}'") + + self._state = float(result.data.octoHeatPumpLifetimePerformance.energyInput.value) + self._attributes["read_at"] = datetime.fromisoformat(result.data.octoHeatPumpLifetimePerformance.readAt) + self._last_updated = current + + self._attributes = dict_to_typed_dict(self._attributes) + super()._handle_coordinator_update() + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None and self._state is None: + 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 OctopusEnergyHeatPumpSensorLifetimeEnergyInput state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/heat_pump/sensor_lifetime_heat_output.py b/custom_components/octopus_energy/heat_pump/sensor_lifetime_heat_output.py new file mode 100644 index 00000000..9a1de051 --- /dev/null +++ b/custom_components/octopus_energy/heat_pump/sensor_lifetime_heat_output.py @@ -0,0 +1,108 @@ +from datetime import datetime +import logging +from typing import List + +from homeassistant.const import ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfEnergy +) +from homeassistant.core import HomeAssistant, callback + +from homeassistant.util.dt import (now) +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity +) +from homeassistant.components.sensor import ( + RestoreSensor, + SensorDeviceClass, + SensorStateClass, +) + +from .base import (BaseOctopusEnergyHeatPumpSensor) +from ..utils.attributes import dict_to_typed_dict +from ..api_client.heat_pump import HeatPump +from ..coordinators.heat_pump_configuration_and_status import HeatPumpCoordinatorResult + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyHeatPumpSensorLifetimeHeatOutput(CoordinatorEntity, BaseOctopusEnergyHeatPumpSensor, RestoreSensor): + """Sensor for displaying the lifetime heat output of a heat pump.""" + + def __init__(self, hass: HomeAssistant, coordinator, heat_pump_id: str, heat_pump: HeatPump): + """Init sensor.""" + # Pass coordinator to base class + CoordinatorEntity.__init__(self, coordinator) + BaseOctopusEnergyHeatPumpSensor.__init__(self, hass, heat_pump_id, heat_pump) + + self._state = None + self._last_updated = None + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_heat_pump_{self._heat_pump_id}_lifetime_heat_output" + + @property + def name(self): + """Name of the sensor.""" + return f"Lifetime Heat Output Heat Pump ({self._heat_pump_id})" + + @property + def state_class(self): + """The state class of sensor""" + return SensorStateClass.TOTAL_INCREASING + + @property + def device_class(self): + """The type of sensor""" + return SensorDeviceClass.ENERGY + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:flash" + + @property + def native_unit_of_measurement(self): + """Unit of measurement of the sensor.""" + return UnitOfEnergy.KILO_WATT_HOUR + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def native_value(self): + return self._state + + @callback + def _handle_coordinator_update(self) -> None: + """Retrieve the lifeime heat output for the heat pump.""" + current = now() + result: HeatPumpCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None + + if (result is not None + and result.data is not None + and result.data.octoHeatPumpLifetimePerformance is not None): + _LOGGER.debug(f"Updating OctopusEnergyHeatPumpSensorLifetimeHeatOutput for '{self._heat_pump_id}'") + + self._state = float(result.data.octoHeatPumpLifetimePerformance.heatOutput.value) + self._attributes["read_at"] = datetime.fromisoformat(result.data.octoHeatPumpLifetimePerformance.readAt) + self._last_updated = current + + self._attributes = dict_to_typed_dict(self._attributes) + super()._handle_coordinator_update() + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None and self._state is None: + 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 OctopusEnergyHeatPumpSensorLifetimeHeatOutput state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/heat_pump/sensor_lifetime_scop.py b/custom_components/octopus_energy/heat_pump/sensor_lifetime_scop.py new file mode 100644 index 00000000..31cac1e1 --- /dev/null +++ b/custom_components/octopus_energy/heat_pump/sensor_lifetime_scop.py @@ -0,0 +1,93 @@ +from datetime import datetime +import logging +from typing import List + +from homeassistant.const import ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfPower +) +from homeassistant.core import HomeAssistant, callback + +from homeassistant.util.dt import (now) +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity +) +from homeassistant.components.sensor import ( + RestoreSensor, + SensorDeviceClass, + SensorStateClass, +) + +from .base import (BaseOctopusEnergyHeatPumpSensor) +from ..utils.attributes import dict_to_typed_dict +from ..api_client.heat_pump import HeatPump +from ..coordinators.heat_pump_configuration_and_status import HeatPumpCoordinatorResult + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyHeatPumpSensorLifetimeSCoP(CoordinatorEntity, BaseOctopusEnergyHeatPumpSensor, RestoreSensor): + """Sensor for displaying the lifetime SCoP of a heat pump.""" + + def __init__(self, hass: HomeAssistant, coordinator, heat_pump_id: str, heat_pump: HeatPump): + """Init sensor.""" + # Pass coordinator to base class + CoordinatorEntity.__init__(self, coordinator) + BaseOctopusEnergyHeatPumpSensor.__init__(self, hass, heat_pump_id, heat_pump) + + self._state = None + self._last_updated = None + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_heat_pump_{self._heat_pump_id}_lifetime_scop" + + @property + def name(self): + """Name of the sensor.""" + return f"Lifetime SCoP Heat Pump ({self._heat_pump_id})" + + @property + def state_class(self): + """The state class of sensor""" + return SensorStateClass.MEASUREMENT + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def native_value(self): + return self._state + + @callback + def _handle_coordinator_update(self) -> None: + """Retrieve the lifetime SCoP for the heat pump.""" + current = now() + result: HeatPumpCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None + + if (result is not None + and result.data is not None + and result.data.octoHeatPumpLifetimePerformance is not None): + _LOGGER.debug(f"Updating OctopusEnergyHeatPumpSensorLifetimeSCoP for '{self._heat_pump_id}'") + + self._state = float(result.data.octoHeatPumpLifetimePerformance.seasonalCoefficientOfPerformance) + self._attributes["read_at"] = datetime.fromisoformat(result.data.octoHeatPumpLifetimePerformance.readAt) + self._last_updated = current + + self._attributes = dict_to_typed_dict(self._attributes) + super()._handle_coordinator_update() + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None and self._state is None: + 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 OctopusEnergyHeatPumpSensorLifetimeSCoP state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/heat_pump/sensor_live_cop.py b/custom_components/octopus_energy/heat_pump/sensor_live_cop.py new file mode 100644 index 00000000..d8221255 --- /dev/null +++ b/custom_components/octopus_energy/heat_pump/sensor_live_cop.py @@ -0,0 +1,97 @@ +from datetime import datetime +import logging +from typing import List + +from homeassistant.const import ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfPower +) +from homeassistant.core import HomeAssistant, callback + +from homeassistant.util.dt import (now) +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity +) +from homeassistant.components.sensor import ( + RestoreSensor, + SensorDeviceClass, + SensorStateClass, +) + +from .base import (BaseOctopusEnergyHeatPumpSensor) +from ..utils.attributes import dict_to_typed_dict +from ..api_client.heat_pump import HeatPump +from ..coordinators.heat_pump_configuration_and_status import HeatPumpCoordinatorResult + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyHeatPumpSensorLiveCoP(CoordinatorEntity, BaseOctopusEnergyHeatPumpSensor, RestoreSensor): + """Sensor for displaying the live CoP of a heat pump.""" + + def __init__(self, hass: HomeAssistant, coordinator, heat_pump_id: str, heat_pump: HeatPump): + """Init sensor.""" + # Pass coordinator to base class + CoordinatorEntity.__init__(self, coordinator) + BaseOctopusEnergyHeatPumpSensor.__init__(self, hass, heat_pump_id, heat_pump) + + self._state = None + self._last_updated = None + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_heat_pump_{self._heat_pump_id}_live_cop" + + @property + def name(self): + """Name of the sensor.""" + return f"Live CoP Heat Pump ({self._heat_pump_id})" + + @property + def state_class(self): + """The state class of sensor""" + return SensorStateClass.MEASUREMENT + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def native_value(self): + return self._state + + @callback + def _handle_coordinator_update(self) -> None: + """Retrieve the live CoP for the heat pump.""" + current = now() + result: HeatPumpCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None + + if (result is not None + and result.data is not None + and result.data.octoHeatPumpLivePerformance is not None): + _LOGGER.debug(f"Updating OctopusEnergyHeatPumpSensorLiveCoP for '{self._heat_pump_id}'") + + self._state = 0 + # Only update the CoP if heat pump is actively running + if float(result.data.octoHeatPumpLivePerformance.powerInput.value) != 0: + self._state = float(result.data.octoHeatPumpLivePerformance.coefficientOfPerformance) + + self._attributes["read_at"] = datetime.fromisoformat(result.data.octoHeatPumpLivePerformance.readAt) + self._last_updated = current + + self._attributes = dict_to_typed_dict(self._attributes) + super()._handle_coordinator_update() + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None and self._state is None: + 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 OctopusEnergyHeatPumpSensorLiveCoP state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/heat_pump/sensor_live_heat_output.py b/custom_components/octopus_energy/heat_pump/sensor_live_heat_output.py new file mode 100644 index 00000000..e8121d67 --- /dev/null +++ b/custom_components/octopus_energy/heat_pump/sensor_live_heat_output.py @@ -0,0 +1,109 @@ +from datetime import datetime +import logging +from typing import List + +from homeassistant.const import ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfPower +) +from homeassistant.core import HomeAssistant, callback + +from homeassistant.util.dt import (now) +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity +) +from homeassistant.components.sensor import ( + RestoreSensor, + SensorDeviceClass, + SensorStateClass, +) + +from .base import (BaseOctopusEnergyHeatPumpSensor) +from ..utils.attributes import dict_to_typed_dict +from ..api_client.heat_pump import HeatPump +from ..coordinators.heat_pump_configuration_and_status import HeatPumpCoordinatorResult + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyHeatPumpSensorLiveHeatOutput(CoordinatorEntity, BaseOctopusEnergyHeatPumpSensor, RestoreSensor): + """Sensor for displaying the live heat output of a heat pump.""" + + def __init__(self, hass: HomeAssistant, coordinator, heat_pump_id: str, heat_pump: HeatPump): + """Init sensor.""" + # Pass coordinator to base class + CoordinatorEntity.__init__(self, coordinator) + BaseOctopusEnergyHeatPumpSensor.__init__(self, hass, heat_pump_id, heat_pump) + + self._state = None + self._last_updated = None + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_heat_pump_{self._heat_pump_id}_live_heat_output" + + @property + def name(self): + """Name of the sensor.""" + return f"Live Heat Output Heat Pump ({self._heat_pump_id})" + + @property + def state_class(self): + """The state class of sensor""" + return SensorStateClass.MEASUREMENT + + @property + def device_class(self): + """The type of sensor""" + return SensorDeviceClass.POWER + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:flash" + + @property + def native_unit_of_measurement(self): + """Unit of measurement of the sensor.""" + return UnitOfPower.KILO_WATT + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def native_value(self): + return self._state + + @callback + def _handle_coordinator_update(self) -> None: + """Retrieve the live heat output for the heat pump.""" + current = now() + result: HeatPumpCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None + + if (result is not None + and result.data is not None + and result.data.octoHeatPumpLivePerformance is not None + and result.data.octoHeatPumpLivePerformance.heatOutput is not None): + _LOGGER.debug(f"Updating OctopusEnergyHeatPumpSensorLiveHeatOutput for '{self._heat_pump_id}'") + + self._state = float(result.data.octoHeatPumpLivePerformance.heatOutput.value) + self._attributes["read_at"] = datetime.fromisoformat(result.data.octoHeatPumpLivePerformance.readAt) + self._last_updated = current + + self._attributes = dict_to_typed_dict(self._attributes) + super()._handle_coordinator_update() + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None and self._state is None: + 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 OctopusEnergyHeatPumpSensorLiveHeatOutput state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/heat_pump/sensor_live_outdoor_temperature.py b/custom_components/octopus_energy/heat_pump/sensor_live_outdoor_temperature.py new file mode 100644 index 00000000..361af0b2 --- /dev/null +++ b/custom_components/octopus_energy/heat_pump/sensor_live_outdoor_temperature.py @@ -0,0 +1,109 @@ +from datetime import datetime +import logging +from typing import List + +from homeassistant.const import ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfTemperature +) +from homeassistant.core import HomeAssistant, callback + +from homeassistant.util.dt import (now) +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity +) +from homeassistant.components.sensor import ( + RestoreSensor, + SensorDeviceClass, + SensorStateClass, +) + +from .base import (BaseOctopusEnergyHeatPumpSensor) +from ..utils.attributes import dict_to_typed_dict +from ..api_client.heat_pump import HeatPump +from ..coordinators.heat_pump_configuration_and_status import HeatPumpCoordinatorResult + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyHeatPumpSensorLiveOutdoorTemperature(CoordinatorEntity, BaseOctopusEnergyHeatPumpSensor, RestoreSensor): + """Sensor for displaying the live heat output of a heat pump.""" + + def __init__(self, hass: HomeAssistant, coordinator, heat_pump_id: str, heat_pump: HeatPump): + """Init sensor.""" + # Pass coordinator to base class + CoordinatorEntity.__init__(self, coordinator) + BaseOctopusEnergyHeatPumpSensor.__init__(self, hass, heat_pump_id, heat_pump) + + self._state = None + self._last_updated = None + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_heat_pump_{self._heat_pump_id}_live_outdoor_temperature" + + @property + def name(self): + """Name of the sensor.""" + return f"Live Outdoor Temperature Heat Pump ({self._heat_pump_id})" + + @property + def state_class(self): + """The state class of sensor""" + return SensorStateClass.MEASUREMENT + + @property + def device_class(self): + """The type of sensor""" + return SensorDeviceClass.TEMPERATURE + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:thermometer" + + @property + def native_unit_of_measurement(self): + """Unit of measurement of the sensor.""" + return UnitOfTemperature.CELSIUS + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def native_value(self): + return self._state + + @callback + def _handle_coordinator_update(self) -> None: + """Retrieve the live heat output for the heat pump.""" + current = now() + result: HeatPumpCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None + + if (result is not None + and result.data is not None + and result.data.octoHeatPumpLivePerformance is not None + and result.data.octoHeatPumpLivePerformance.outdoorTemperature is not None): + _LOGGER.debug(f"Updating OctopusEnergyHeatPumpSensorLiveOutdoorTemperature for '{self._heat_pump_id}'") + + self._state = float(result.data.octoHeatPumpLivePerformance.outdoorTemperature.value) + self._attributes["read_at"] = datetime.fromisoformat(result.data.octoHeatPumpLivePerformance.readAt) + self._last_updated = current + + self._attributes = dict_to_typed_dict(self._attributes) + super()._handle_coordinator_update() + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None and self._state is None: + 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 OctopusEnergyHeatPumpSensorLiveOutdoorTemperature state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/heat_pump/sensor_live_power_input.py b/custom_components/octopus_energy/heat_pump/sensor_live_power_input.py new file mode 100644 index 00000000..6d43946f --- /dev/null +++ b/custom_components/octopus_energy/heat_pump/sensor_live_power_input.py @@ -0,0 +1,109 @@ +from datetime import datetime +import logging +from typing import List + +from homeassistant.const import ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfPower +) +from homeassistant.core import HomeAssistant, callback + +from homeassistant.util.dt import (now) +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity +) +from homeassistant.components.sensor import ( + RestoreSensor, + SensorDeviceClass, + SensorStateClass, +) + +from .base import (BaseOctopusEnergyHeatPumpSensor) +from ..utils.attributes import dict_to_typed_dict +from ..api_client.heat_pump import HeatPump +from ..coordinators.heat_pump_configuration_and_status import HeatPumpCoordinatorResult + +_LOGGER = logging.getLogger(__name__) + +class OctopusEnergyHeatPumpSensorLivePowerInput(CoordinatorEntity, BaseOctopusEnergyHeatPumpSensor, RestoreSensor): + """Sensor for displaying the live power input of a heat pump.""" + + def __init__(self, hass: HomeAssistant, coordinator, heat_pump_id: str, heat_pump: HeatPump): + """Init sensor.""" + # Pass coordinator to base class + CoordinatorEntity.__init__(self, coordinator) + BaseOctopusEnergyHeatPumpSensor.__init__(self, hass, heat_pump_id, heat_pump) + + self._state = None + self._last_updated = None + + @property + def unique_id(self): + """The id of the sensor.""" + return f"octopus_energy_heat_pump_{self._heat_pump_id}_live_power_input" + + @property + def name(self): + """Name of the sensor.""" + return f"Live Power Input Heat Pump ({self._heat_pump_id})" + + @property + def state_class(self): + """The state class of sensor""" + return SensorStateClass.MEASUREMENT + + @property + def device_class(self): + """The type of sensor""" + return SensorDeviceClass.POWER + + @property + def icon(self): + """Icon of the sensor.""" + return "mdi:flash" + + @property + def native_unit_of_measurement(self): + """Unit of measurement of the sensor.""" + return UnitOfPower.KILO_WATT + + @property + def extra_state_attributes(self): + """Attributes of the sensor.""" + return self._attributes + + @property + def native_value(self): + return self._state + + @callback + def _handle_coordinator_update(self) -> None: + """Retrieve the live power draw for the heat pump.""" + current = now() + result: HeatPumpCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None + + if (result is not None + and result.data is not None + and result.data.octoHeatPumpLivePerformance is not None + and result.data.octoHeatPumpLivePerformance.powerInput is not None): + _LOGGER.debug(f"Updating OctopusEnergyHeatPumpSensorLivePowerInput for '{self._heat_pump_id}'") + + self._state = float(result.data.octoHeatPumpLivePerformance.powerInput.value) + self._attributes["read_at"] = datetime.fromisoformat(result.data.octoHeatPumpLivePerformance.readAt) + self._last_updated = current + + self._attributes = dict_to_typed_dict(self._attributes) + super()._handle_coordinator_update() + + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + # If not None, we got an initial value. + await super().async_added_to_hass() + state = await self.async_get_last_state() + + if state is not None and self._state is None: + 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 OctopusEnergyHeatPumpSensorLivePowerInput state: {self._state}') \ No newline at end of file diff --git a/custom_components/octopus_energy/heat_pump/sensor_temperature.py b/custom_components/octopus_energy/heat_pump/sensor_temperature.py index 1134fac4..0d71cb22 100644 --- a/custom_components/octopus_energy/heat_pump/sensor_temperature.py +++ b/custom_components/octopus_energy/heat_pump/sensor_temperature.py @@ -79,8 +79,7 @@ def native_value(self): @callback def _handle_coordinator_update(self) -> None: - """Retrieve the previous rate.""" - # Find the previous rate. We only need to do this every half an hour + """Retrieve the current sensor temperature.""" current = now() result: HeatPumpCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None if (result is not None and diff --git a/custom_components/octopus_energy/heat_pump/zone.py b/custom_components/octopus_energy/heat_pump/zone.py index 55152e1c..78b1217b 100644 --- a/custom_components/octopus_energy/heat_pump/zone.py +++ b/custom_components/octopus_energy/heat_pump/zone.py @@ -21,6 +21,7 @@ from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, + HVACAction, HVACMode, PRESET_NONE, PRESET_BOOST, @@ -44,6 +45,8 @@ class OctopusEnergyHeatPumpZone(CoordinatorEntity, BaseOctopusEnergyHeatPumpSens | ClimateEntityFeature.PRESET_MODE ) + _attr_hvac_actions = [HVACAction.HEATING, HVACAction.IDLE] + _attr_hvac_action = None _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF, HVACMode.AUTO] _attr_hvac_mode = None _attr_preset_modes = [PRESET_NONE, PRESET_BOOST] @@ -89,42 +92,52 @@ def _handle_coordinator_update(self) -> None: """Retrieve the previous rate.""" current = now() - result: HeatPumpCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None + result: HeatPumpCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None if (result is not None and result.data is not None and - result.data.octoHeatPumpControllerConfiguration is not None and - result.data.octoHeatPumpControllerConfiguration.zones and + result.data.octoHeatPumpControllerStatus is not None and + result.data.octoHeatPumpControllerStatus.zones and (self._last_updated is None or self._last_updated < result.last_retrieved)): _LOGGER.debug(f"Updating OctopusEnergyHeatPumpZone for '{self._heat_pump_id}/{self._zone.configuration.code}'") - zones: List[ConfigurationZone] = result.data.octoHeatPumpControllerConfiguration.zones + zones: List[Zone] = result.data.octoHeatPumpControllerStatus.zones for zone in zones: - if zone.configuration is not None and zone.configuration.code == self._zone.configuration.code and zone.configuration.currentOperation is not None: + if zone.telemetry is not None and zone.zone == self._zone.configuration.code and zone.telemetry.mode is not None: - if zone.configuration.currentOperation.mode == "ON": + if zone.telemetry.mode == "ON": self._attr_hvac_mode = HVACMode.HEAT self._attr_preset_mode = PRESET_NONE - elif zone.configuration.currentOperation.mode == "OFF": + elif zone.telemetry.mode == "OFF": self._attr_hvac_mode = HVACMode.OFF self._attr_preset_mode = PRESET_NONE - elif zone.configuration.currentOperation.mode == "AUTO": + elif zone.telemetry.mode == "AUTO": self._attr_hvac_mode = HVACMode.AUTO self._attr_preset_mode = PRESET_NONE - elif zone.configuration.currentOperation.mode == "BOOST": + elif zone.telemetry.mode == "BOOST": self._attr_hvac_mode = HVACMode.AUTO self._attr_preset_mode = PRESET_BOOST else: - raise Exception(f"Unexpected heat pump mode detected: {zone.configuration.currentOperation.mode}") + raise Exception(f"Unexpected heat pump mode detected: {zone.telemetry.mode}") - self._attr_target_temperature = zone.configuration.currentOperation.setpointInCelsius - self._end_timestamp = datetime.fromisoformat(zone.configuration.currentOperation.end) if zone.configuration.currentOperation.end is not None else None + self._attr_hvac_action = HVACAction.HEATING if zone.telemetry.relaySwitchedOn else HVACAction.IDLE - if (result.data.octoHeatPumpControllerStatus.sensors and self._zone.configuration.primarySensor): + self._attr_target_temperature = None + if zone.telemetry.setpointInCelsius is not None and zone.telemetry.setpointInCelsius > 0: + self._attr_target_temperature = zone.telemetry.setpointInCelsius + + if result.data.octoHeatPumpControllerStatus.sensors and self._zone.configuration.primarySensor: sensors: List[Sensor] = result.data.octoHeatPumpControllerStatus.sensors for sensor in sensors: if sensor.code == self._zone.configuration.primarySensor and sensor.telemetry is not None: self._attr_current_temperature = sensor.telemetry.temperatureInCelsius + self._attr_current_humidity = sensor.telemetry.humidityPercentage + if result.data.octoHeatPumpControllerConfiguration is not None and result.data.octoHeatPumpControllerConfiguration.zones: + configs: List[ConfigurationZone] = result.data.octoHeatPumpControllerConfiguration.zones + for config in configs: + if config.configuration is not None and config.configuration.code == self._zone.configuration.code and config.configuration.currentOperation is not None: + self._end_timestamp = datetime.fromisoformat(config.configuration.currentOperation.end) if config.configuration.currentOperation.end is not None else None + self._last_updated = current self._attributes = dict_to_typed_dict(self._attributes) diff --git a/custom_components/octopus_energy/icons.json b/custom_components/octopus_energy/icons.json index 4ab4a308..2ba261ad 100644 --- a/custom_components/octopus_energy/icons.json +++ b/custom_components/octopus_energy/icons.json @@ -11,6 +11,7 @@ "adjust_accumulative_cost_tracker": "mdi:numeric", "adjust_cost_tracker": "mdi:numeric", "redeem_octoplus_points_into_account_credit": "mdi:currency-gbp", - "boost_heat_pump_zone": "mdi:thermometer-plus" + "boost_heat_pump_zone": "mdi:thermometer-plus", + "set_heat_pump_flow_temp_config": "mdi:heat-pump" } } \ No newline at end of file diff --git a/custom_components/octopus_energy/sensor.py b/custom_components/octopus_energy/sensor.py index fe868003..032036d3 100644 --- a/custom_components/octopus_energy/sensor.py +++ b/custom_components/octopus_energy/sensor.py @@ -60,6 +60,13 @@ from .heat_pump import get_mock_heat_pump_id from .heat_pump.sensor_temperature import OctopusEnergyHeatPumpSensorTemperature from .heat_pump.sensor_humidity import OctopusEnergyHeatPumpSensorHumidity +from .heat_pump.sensor_live_power_input import OctopusEnergyHeatPumpSensorLivePowerInput +from .heat_pump.sensor_live_heat_output import OctopusEnergyHeatPumpSensorLiveHeatOutput +from .heat_pump.sensor_live_cop import OctopusEnergyHeatPumpSensorLiveCoP +from .heat_pump.sensor_live_outdoor_temperature import OctopusEnergyHeatPumpSensorLiveOutdoorTemperature +from .heat_pump.sensor_lifetime_scop import OctopusEnergyHeatPumpSensorLifetimeSCoP +from .heat_pump.sensor_lifetime_heat_output import OctopusEnergyHeatPumpSensorLifetimeHeatOutput +from .heat_pump.sensor_lifetime_energy_input import OctopusEnergyHeatPumpSensorLifetimeEnergyInput from .api_client.intelligent_device import IntelligentDevice from .intelligent.current_state import OctopusEnergyIntelligentCurrentState from .intelligent import get_intelligent_features @@ -563,10 +570,13 @@ def setup_heat_pump_sensors(hass: HomeAssistant, account_id: str, heat_pump_id: entities = [] + if heat_pump_response is None: + return entities + if coordinator is not None: entities.append(OctopusEnergyHeatPumpDataLastRetrieved(hass, coordinator, account_id, heat_pump_id)) - if heat_pump_response is not None and heat_pump_response.octoHeatPumpControllerConfiguration is not None: + if heat_pump_response.octoHeatPumpControllerConfiguration is not None: for zone in heat_pump_response.octoHeatPumpControllerConfiguration.zones: if zone.configuration is not None and zone.configuration.sensors is not None: if zone.configuration.enabled == False: @@ -593,6 +603,57 @@ def setup_heat_pump_sensors(hass: HomeAssistant, account_id: str, heat_pump_id: sensor )) + if heat_pump_response.octoHeatPumpLivePerformance is not None: + entities.append(OctopusEnergyHeatPumpSensorLivePowerInput( + hass, + coordinator, + heat_pump_id, + heat_pump_response.octoHeatPumpControllerConfiguration.heatPump + )) + + entities.append(OctopusEnergyHeatPumpSensorLiveHeatOutput( + hass, + coordinator, + heat_pump_id, + heat_pump_response.octoHeatPumpControllerConfiguration.heatPump + )) + + entities.append(OctopusEnergyHeatPumpSensorLiveCoP( + hass, + coordinator, + heat_pump_id, + heat_pump_response.octoHeatPumpControllerConfiguration.heatPump + )) + + entities.append(OctopusEnergyHeatPumpSensorLiveOutdoorTemperature( + hass, + coordinator, + heat_pump_id, + heat_pump_response.octoHeatPumpControllerConfiguration.heatPump + )) + + if heat_pump_response.octoHeatPumpLifetimePerformance is not None: + entities.append(OctopusEnergyHeatPumpSensorLifetimeEnergyInput( + hass, + coordinator, + heat_pump_id, + heat_pump_response.octoHeatPumpControllerConfiguration.heatPump + )) + + entities.append(OctopusEnergyHeatPumpSensorLifetimeHeatOutput( + hass, + coordinator, + heat_pump_id, + heat_pump_response.octoHeatPumpControllerConfiguration.heatPump + )) + + entities.append(OctopusEnergyHeatPumpSensorLifetimeSCoP( + hass, + coordinator, + heat_pump_id, + heat_pump_response.octoHeatPumpControllerConfiguration.heatPump + )) + return entities async def async_setup_cost_sensors(hass: HomeAssistant, entry, config, async_add_entities): diff --git a/custom_components/octopus_energy/services.yaml b/custom_components/octopus_energy/services.yaml index c0c2655f..ad8c6ca5 100644 --- a/custom_components/octopus_energy/services.yaml +++ b/custom_components/octopus_energy/services.yaml @@ -299,4 +299,49 @@ boost_heat_pump_zone: selector: number: step: 0.5 + mode: box + +set_heat_pump_flow_temp_config: + name: Set heat pump flow temperature configuration + description: Sets the full flow temperature configuration for fixed and weather compensated flow temperatures, with the option to select which is active. Changing this configuration without a good understanding of heat loss and emitter output can cause cycling, defrosting, or incorrect heat delivery. + target: + entity: + integration: octopus_energy + domain: climate + fields: + weather_comp_enabled: + name: Weather compensation enabled + description: Switches weather compensation on or off + required: true + selector: + boolean: + weather_comp_min_temperature: + name: Weather compensated minimum temperature + description: Minimum allowable temperature for weather compensation + required: true + selector: + number: + step: 0.5 + min: 30 + max: 70 + mode: box + weather_comp_max_temperature: + name: Weather compensated maximum temperature + description: Maximum allowable temperature for weather compensation + required: true + selector: + number: + step: 0.5 + min: 30 + max: 70 + mode: box + fixed_flow_temperature: + name: Fixed flow temperature + description: Temperature for a fixed flow without weather compensation + required: true + selector: + number: + step: 0.5 + min: 30 + max: 70 mode: box \ No newline at end of file diff --git a/custom_components/octopus_energy/storage/heat_pump.py b/custom_components/octopus_energy/storage/heat_pump.py index 4126b041..091f2a10 100644 --- a/custom_components/octopus_energy/storage/heat_pump.py +++ b/custom_components/octopus_energy/storage/heat_pump.py @@ -6,18 +6,18 @@ _LOGGER = logging.getLogger(__name__) async def async_load_cached_heat_pump(hass, account_id: str, euid: str) -> HeatPumpResponse: - store = storage.Store(hass, "1", f"octopus_energy.{account_id}_{euid}_heat_pump") + store = storage.Store(hass, "2", f"octopus_energy.{account_id}_{euid}_heat_pump") try: data = await store.async_load() if data is not None: - _LOGGER.debug(f"Loaded cached intelligent device data for {account_id}/{euid}") + _LOGGER.debug(f"Loaded cached heat pump data for {account_id}/{euid}") return HeatPumpResponse.parse_obj(data) except: return None async def async_save_cached_heat_pump(hass, account_id: str, euid: str, heat_pump: HeatPumpResponse): if heat_pump is not None: - store = storage.Store(hass, "1", f"octopus_energy.{account_id}_{euid}_heat_pump") + store = storage.Store(hass, "2", f"octopus_energy.{account_id}_{euid}_heat_pump") await store.async_save(heat_pump.dict()) - _LOGGER.debug(f"Saved heat pymp data for {account_id}/{euid}") \ No newline at end of file + _LOGGER.debug(f"Saved heat pump data for {account_id}/{euid}") From dc3ababfc2a14572b731e839559f9fcc0275bb10 Mon Sep 17 00:00:00 2001 From: reidjr2 <129611290+reidjr2@users.noreply.github.com> Date: Tue, 31 Dec 2024 15:14:09 +0000 Subject: [PATCH 14/18] Update __init__.py Apply divisor to instdmand --- .../octopus_energy/api_client_home_pro/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/octopus_energy/api_client_home_pro/__init__.py b/custom_components/octopus_energy/api_client_home_pro/__init__.py index 1fee6669..506085d0 100644 --- a/custom_components/octopus_energy/api_client_home_pro/__init__.py +++ b/custom_components/octopus_energy/api_client_home_pro/__init__.py @@ -73,7 +73,7 @@ async def async_get_consumption(self, is_electricity: bool) -> list | None: divisor = int(data["raw"]["divisor"], 16) return [{ "total_consumption": int(data["consumption"]) / divisor if divisor > 0 else None, - "demand": float(data["instdmand"]) if "instdmand" in data else None, + "demand": float(data["instdmand"]) / (divisor / 1000) if divisor > 0 and "instdmand" in data else None, "start": datetime.fromtimestamp(int(meter_consump["time"]), timezone.utc), "end": datetime.fromtimestamp(int(meter_consump["time"]), timezone.utc), "is_kwh": data["unit"] == 0 @@ -141,4 +141,4 @@ async def __async_read_response__(self, response, url): except: raise Exception(f'Failed to extract response json: {url}; {text}') - return data_as_json \ No newline at end of file + return data_as_json From 6594c67037141fcbfb9570fb9011f6bba293abac Mon Sep 17 00:00:00 2001 From: Lewis Juggins <873275+lwis@users.noreply.github.com> Date: Tue, 31 Dec 2024 21:40:38 +0000 Subject: [PATCH 15/18] Fix update flow temperature service --- .../octopus_energy/api_client/__init__.py | 17 ++++++++++------- .../octopus_energy/heat_pump/zone.py | 13 +++++++++++++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/custom_components/octopus_energy/api_client/__init__.py b/custom_components/octopus_energy/api_client/__init__.py index 7d506e72..378b93b5 100644 --- a/custom_components/octopus_energy/api_client/__init__.py +++ b/custom_components/octopus_energy/api_client/__init__.py @@ -390,8 +390,8 @@ octoHeatPumpUpdateFlowTemperatureConfiguration( euid: "{euid}" flowTemperatureInput: {{ - useWeatherCompensation: {use_weather_comp}, - flowTemperature: { + useWeatherCompensation: {weather_comp_enabled}, + flowTemperature: {{ value: "{fixed_flow_temperature}", unit: DEGREES_CELSIUS }}, @@ -399,13 +399,16 @@ minimum: {{ value: "{weather_comp_min_temperature}", unit: DEGREES_CELSIUS - }, + }}, maximum: {{ value: "{weather_comp_max_temperature}", unit: DEGREES_CELSIUS }} + }} }} - ) + ) {{ + transactionId + }} }} ''' @@ -870,14 +873,14 @@ async def async_get_heat_pump_configuration_and_status(self, account_id: str, eu _LOGGER.warning(f'Failed to connect. Timeout of {self._timeout} exceeded.') raise TimeoutException() - async def async_set_heat_pump_flow_temp_config(self, euid: str, weather_comp_enabled: bool, weather_comp_min_temperature: float | None, weather_comp_max_temperature: float | None, fixed_flow_temperature: float | None): - """Sets the flow temperature for a given heat pump zone""" + async def async_set_heat_pump_flow_temp_config(self, euid: str, weather_comp_enabled: bool, weather_comp_min_temperature: float, weather_comp_max_temperature: float, fixed_flow_temperature: float): + """Sets the flow temperature for a given heat pump zone""" await self.async_refresh_token() try: client = self._create_client_session() url = f'{self._base_url}/v1/graphql/' - query = heat_pump_update_flow_temp_config_mutation.format(euid=euid, weather_comp_enabled=weather_comp_enabled, weather_comp_min_temperature=weather_comp_min_temperature, weather_comp_max_temperature=weather_comp_max_temperature, fixed_flow_temperature=fixed_flow_temperature) + query = heat_pump_update_flow_temp_config_mutation.format(euid=euid, weather_comp_enabled=str(weather_comp_enabled).lower(), weather_comp_min_temperature=weather_comp_min_temperature, weather_comp_max_temperature=weather_comp_max_temperature, fixed_flow_temperature=fixed_flow_temperature) payload = { "query": query } headers = { "Authorization": f"JWT {self._graphql_token}" } async with client.post(url, json=payload, headers=headers) as heat_pump_response: diff --git a/custom_components/octopus_energy/heat_pump/zone.py b/custom_components/octopus_energy/heat_pump/zone.py index 78b1217b..a3dc1058 100644 --- a/custom_components/octopus_energy/heat_pump/zone.py +++ b/custom_components/octopus_energy/heat_pump/zone.py @@ -246,6 +246,19 @@ async def async_boost_heat_pump_zone(self, hours: int, minutes: int, target_temp self.async_write_ha_state() + @callback + async def async_set_heat_pump_flow_temp_config(self, weather_comp_enabled: bool, weather_comp_min_temperature: float, weather_comp_max_temperature: float, fixed_flow_temperature: float): + """Update flow temperature configuration""" + try: + await self._client.async_set_heat_pump_flow_temp_config(self._heat_pump_id, weather_comp_enabled, weather_comp_min_temperature, weather_comp_max_temperature, fixed_flow_temperature) + except Exception as e: + if self._is_mocked: + _LOGGER.warning(f'Suppress async_turn_on error due to mocking mode: {e}') + else: + raise + + self.async_write_ha_state() + def get_zone_mode(self): if self._attr_preset_mode == PRESET_BOOST: return "BOOST" From 0bd38affc3178bde899a1d41ce7909a9d0e526e5 Mon Sep 17 00:00:00 2001 From: David Kendall Date: Wed, 1 Jan 2025 06:01:30 +0000 Subject: [PATCH 16/18] feat: Added manual reset mode to cost trackers (1.5 hours dev time) --- _docs/setup/cost_tracker.md | 13 ++++ .../octopus_energy/config_flow.py | 4 + custom_components/octopus_energy/const.py | 1 + .../octopus_energy/cost_tracker/__init__.py | 12 +-- .../cost_tracker/cost_tracker.py | 4 +- .../octopus_energy/translations/en.json | 12 ++- requirements.test.txt | 1 - tests/unit/api_client/test_heat_pump.py | 28 +++++++ .../unit/cost_tracker/test_add_consumption.py | 75 +++++++++++++++++++ 9 files changed, 141 insertions(+), 9 deletions(-) diff --git a/_docs/setup/cost_tracker.md b/_docs/setup/cost_tracker.md index 5fc6dd2f..6d22ee1e 100644 --- a/_docs/setup/cost_tracker.md +++ b/_docs/setup/cost_tracker.md @@ -32,6 +32,18 @@ This should be true if the tracked entity's state increases over time (true) or However, there have [been reports](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/issues/901) of some sensors misbehaving. To counteract this, if there is less than a 10% decrease, then the difference between the new and old state will be recorded. +### Don't automatically reset the cost sensor + +By default, the sensor will automatically reset when a new reading has been received and it's a different day to the previous reading. There may be times that you want to track costs for something over this threshold (e.g. how much it last cost you to charge your car). In these scenarios, you can turn off the automatic resets. In this scenario, you are in charge of resetting the core sensor via it's [available service](../services.md#octopus_energyreset_cost_tracker). + +!!! info + + The weekly and monthly sensors will reset as normal with this settings turned on. + +!!! warning + + Not resetting this sensor for long periods of time can cause Home Assistant warnings around recording of state. This results in the attributes of the sensor not being persisted to the database for long term storage. This is a known limitation of the feature and cannot be fixed due to how the sensor tracks cost. + ### Week sensor day reset This is the day of the week the accumulative week sensor should reset. This defaults to Monday. @@ -61,6 +73,7 @@ This is in pounds and pence (e.g. 1.01 = £1.01). | `target_entity_id` | `string` | The entity whose consumption data is being tracked (based on config) | | `entity_accumulative_value` | `boolean` | Determines if the tracked entity has accumulative data (based on config) | | `account_id` | `string` | The id of the account the cost tracker is for (based on config) | +| `is_manual` | `boolean` | Determines if the tracker only resets when manually reset via the available service | | `is_tracking` | `boolean` | Determines if the tracker is currently tracking consumption/cost data | | `tracked_changes` | `array` | The collection of tracked entity changes where the costs have been tracked in 30 minute increments | | `untracked_changes` | `array` | The collection of tracked entity changes where the costs have **not** been tracked in 30 minute increments | diff --git a/custom_components/octopus_energy/config_flow.py b/custom_components/octopus_energy/config_flow.py index bc2fa4ea..fbec139f 100644 --- a/custom_components/octopus_energy/config_flow.py +++ b/custom_components/octopus_energy/config_flow.py @@ -16,6 +16,7 @@ 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_TRACKER_MANUAL_RESET, CONFIG_FAVOUR_DIRECT_DEBIT_RATES, CONFIG_KIND_ROLLING_TARGET_RATE, CONFIG_MAIN_HOME_PRO_ADDRESS, @@ -326,6 +327,7 @@ async def __async_setup_cost_tracker_schema__(self, account_id: str): selector.EntitySelectorConfig(domain="sensor", device_class=[SensorDeviceClass.ENERGY]), ), vol.Optional(CONFIG_COST_TRACKER_ENTITY_ACCUMULATIVE_VALUE, default=False): bool, + vol.Required(CONFIG_COST_TRACKER_MANUAL_RESET, default=False): bool, vol.Required(CONFIG_COST_TRACKER_WEEKDAY_RESET, default="0"): selector.SelectSelector( selector.SelectSelectorConfig( options=get_weekday_options(), @@ -839,6 +841,7 @@ async def __async_setup_cost_tracker_schema__(self, config, errors): selector.EntitySelectorConfig(domain="sensor", device_class=[SensorDeviceClass.ENERGY]), ), vol.Optional(CONFIG_COST_TRACKER_ENTITY_ACCUMULATIVE_VALUE): bool, + vol.Required(CONFIG_COST_TRACKER_MANUAL_RESET): bool, vol.Required(CONFIG_COST_TRACKER_WEEKDAY_RESET): selector.SelectSelector( selector.SelectSelectorConfig( options=get_weekday_options(), @@ -854,6 +857,7 @@ async def __async_setup_cost_tracker_schema__(self, config, errors): CONFIG_COST_TRACKER_ENTITY_ACCUMULATIVE_VALUE: config[CONFIG_COST_TRACKER_ENTITY_ACCUMULATIVE_VALUE], CONFIG_COST_TRACKER_WEEKDAY_RESET: f"{config[CONFIG_COST_TRACKER_WEEKDAY_RESET]}" if CONFIG_COST_TRACKER_WEEKDAY_RESET in config else "0", CONFIG_COST_TRACKER_MONTH_DAY_RESET: config[CONFIG_COST_TRACKER_MONTH_DAY_RESET] if CONFIG_COST_TRACKER_MONTH_DAY_RESET in config else 1, + CONFIG_COST_TRACKER_MANUAL_RESET: config[CONFIG_COST_TRACKER_MANUAL_RESET] if CONFIG_COST_TRACKER_MANUAL_RESET in config else False } ), errors=errors diff --git a/custom_components/octopus_energy/const.py b/custom_components/octopus_energy/const.py index b1c1b368..351218a3 100644 --- a/custom_components/octopus_energy/const.py +++ b/custom_components/octopus_energy/const.py @@ -104,6 +104,7 @@ CONFIG_COST_TRACKER_ENTITY_ACCUMULATIVE_VALUE = "entity_accumulative_value" CONFIG_COST_TRACKER_WEEKDAY_RESET = "weekday_reset" CONFIG_COST_TRACKER_MONTH_DAY_RESET = "month_day_reset" +CONFIG_COST_TRACKER_MANUAL_RESET = "manual_reset" CONFIG_TARIFF_COMPARISON_NAME = "name" CONFIG_TARIFF_COMPARISON_MPAN_MPRN = "mpan_mprn" diff --git a/custom_components/octopus_energy/cost_tracker/__init__.py b/custom_components/octopus_energy/cost_tracker/__init__.py index c0d81f91..2e6adaa3 100644 --- a/custom_components/octopus_energy/cost_tracker/__init__.py +++ b/custom_components/octopus_energy/cost_tracker/__init__.py @@ -53,6 +53,7 @@ def add_consumption(current: datetime, old_last_reset: datetime, is_accumulative_value: bool, is_tracking: bool, + is_manual_reset_enabled: bool = False, state_class: str = None): if new_value is None: return @@ -85,12 +86,13 @@ def add_consumption(current: datetime, new_tracked_consumption_data = tracked_consumption_data.copy() new_untracked_consumption_data = untracked_consumption_data.copy() - # If we've gone into a new day, then reset the consumption result - if ((new_tracked_consumption_data is not None and len(new_tracked_consumption_data) > 0 and - (new_tracked_consumption_data[0]["start"].year != start_of_day.year or new_tracked_consumption_data[0]["start"].month != start_of_day.month or new_tracked_consumption_data[0]["start"].day != start_of_day.day)) or + # If we've gone into a new day, then reset the consumption result, unless manual reset is enabled + if (is_manual_reset_enabled == False and + ((new_tracked_consumption_data is not None and len(new_tracked_consumption_data) > 0 and + (new_tracked_consumption_data[0]["start"].year != start_of_day.year or new_tracked_consumption_data[0]["start"].month != start_of_day.month or new_tracked_consumption_data[0]["start"].day != start_of_day.day)) or - (new_untracked_consumption_data is not None and len(new_untracked_consumption_data) > 0 and - (new_untracked_consumption_data[0]["start"].year != start_of_day.year or new_untracked_consumption_data[0]["start"].month != start_of_day.month or new_untracked_consumption_data[0]["start"].day != start_of_day.day))): + (new_untracked_consumption_data is not None and len(new_untracked_consumption_data) > 0 and + (new_untracked_consumption_data[0]["start"].year != start_of_day.year or new_untracked_consumption_data[0]["start"].month != start_of_day.month or new_untracked_consumption_data[0]["start"].day != start_of_day.day)))): new_tracked_consumption_data = [] new_untracked_consumption_data = [] diff --git a/custom_components/octopus_energy/cost_tracker/cost_tracker.py b/custom_components/octopus_energy/cost_tracker/cost_tracker.py index a404a4da..8a92b8df 100644 --- a/custom_components/octopus_energy/cost_tracker/cost_tracker.py +++ b/custom_components/octopus_energy/cost_tracker/cost_tracker.py @@ -28,6 +28,7 @@ from ..const import ( CONFIG_COST_TRACKER_ENTITY_ACCUMULATIVE_VALUE, + CONFIG_COST_TRACKER_MANUAL_RESET, CONFIG_COST_TRACKER_TARGET_ENTITY_ID, CONFIG_COST_TRACKER_NAME, DOMAIN, @@ -211,6 +212,7 @@ async def _async_calculate_cost(self, event: Event[EventStateChangedData]): old_last_reset, self._config[CONFIG_COST_TRACKER_ENTITY_ACCUMULATIVE_VALUE], self._attributes["is_tracking"] if "is_tracking" in self._attributes else True, + CONFIG_COST_TRACKER_MANUAL_RESET in self._config and self._config[CONFIG_COST_TRACKER_MANUAL_RESET] == True, new_state.attributes["state_class"] if "state_class" in new_state.attributes else None) @@ -321,7 +323,7 @@ def _reset_if_new_day(self, current: datetime): self._last_reset = start_of_day return True - if self._last_reset.date() != current.date(): + if self._last_reset.date() != current.date() and (CONFIG_COST_TRACKER_MANUAL_RESET not in self._config or self._config[CONFIG_COST_TRACKER_MANUAL_RESET] == False): self._state = 0 self._attributes["tracked_charges"] = [] self._attributes["untracked_charges"] = [] diff --git a/custom_components/octopus_energy/translations/en.json b/custom_components/octopus_energy/translations/en.json index dff3f0be..5234ec78 100644 --- a/custom_components/octopus_energy/translations/en.json +++ b/custom_components/octopus_energy/translations/en.json @@ -92,7 +92,11 @@ "target_entity_id": "The entity to track the costs for.", "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" + "month_day_reset": "The day when the month cost sensor should reset", + "manual_reset": "Don't automatically reset the cost sensor" + }, + "data_description": { + "manual_reset": "If enabled, you will need to manually reset this sensor via the available service. You will also not get week and month sensors. This may produce Home Assistant warnings if not reset for long periods of times." } }, "cost_tracker_account": { @@ -226,7 +230,11 @@ "target_entity_id": "The entity to track the costs for.", "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" + "month_day_reset": "The day when the month cost sensor should reset", + "manual_reset": "Don't automatically reset the cost sensor" + }, + "data_description": { + "manual_reset": "If enabled, you will need to manually reset this sensor via the available service. You will also not get week and month sensors. This may produce Home Assistant warnings if not reset for long periods of times." } }, "tariff_comparison": { diff --git a/requirements.test.txt b/requirements.test.txt index 8f62ad23..b22f4af1 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -5,7 +5,6 @@ mock homeassistant pydantic psutil-home-assistant -pydantic sqlalchemy fnvhash fnv_hash_fast \ No newline at end of file diff --git a/tests/unit/api_client/test_heat_pump.py b/tests/unit/api_client/test_heat_pump.py index 7db745ec..eb941147 100644 --- a/tests/unit/api_client/test_heat_pump.py +++ b/tests/unit/api_client/test_heat_pump.py @@ -336,6 +336,34 @@ def test_when_valid_dictionary_returned_then_it_can_be_parsed_into_heat_pump_obj } } ] + }, + "octoHeatPumpLifetimePerformance": { + "seasonalCoefficientOfPerformance": "3.5", + "heatOutput": { + "unit": "KILOWATT_HOUR", + "value": "100.4" + }, + "energyInput": { + "unit": "KILOWATT_HOUR", + "value": "100.2" + }, + "readAt": "2024-12-31T09:10:00+00:00" + }, + "octoHeatPumpLivePerformance": { + "coefficientOfPerformance": "3.4", + "outdoorTemperature": { + "unit": "DEGREES_CELSIUS", + "value": "30.1" + }, + "heatOutput": { + "value": "10.5", + "unit": "KILOWATT" + }, + "powerInput": { + "unit": "KILOWATT", + "value": "5.4" + }, + "readAt": "2024-12-31T09:10:00+00:00" } } diff --git a/tests/unit/cost_tracker/test_add_consumption.py b/tests/unit/cost_tracker/test_add_consumption.py index dbe9a2c8..e6912602 100644 --- a/tests/unit/cost_tracker/test_add_consumption.py +++ b/tests/unit/cost_tracker/test_add_consumption.py @@ -196,6 +196,7 @@ async def test_when_state_class_total_increasing_and_new_value_less_than_old_val old_last_reset, is_accumulative_value, is_tracking, + False, "total_increasing") # Assert @@ -232,6 +233,7 @@ async def test_when_state_class_total_increasing_and_new_value_less_than_old_val old_last_reset, is_accumulative_value, is_tracking, + False, "total_increasing") # Assert @@ -267,6 +269,7 @@ async def test_when_state_class_total_increasing_and_new_value_greater_than_old_ old_last_reset, is_accumulative_value, is_tracking, + False, "total_increasing") # Assert @@ -385,6 +388,78 @@ async def test_when_consumption_exists_and_new_day_starts_then_consumption_added assert len(result.tracked_consumption_data) == 0 assert_consumption(result.untracked_consumption_data, datetime.strptime("2022-02-28T10:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), datetime.strptime("2022-02-28T10:30:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), 0.1) +@pytest.mark.asyncio +@pytest.mark.parametrize("is_tracking", [(True),(False)]) +async def test_when_consumption_exists_and_new_day_starts_and_manual_reset_is_enabled_then_consumption_added_to_existing_day(is_tracking: bool): + + # Arrange + existing_start = datetime.strptime("2022-02-27T10:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z") + existing_end = existing_start + timedelta(minutes=30) + + current = datetime.strptime(f"2022-02-28T10:15:00+00:00", "%Y-%m-%dT%H:%M:%S%z") + expected_start = datetime.strptime("2022-02-28T10:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z") + expected_end = expected_start + timedelta(minutes=30) + + tracked_consumption_data = [] + untracked_consumption_data = [] + + if is_tracking: + tracked_consumption_data.append({ + "start": existing_start, + "end": existing_end, + "consumption": 0.2 + }) + else: + untracked_consumption_data.append({ + "start": existing_start, + "end": existing_end, + "consumption": 0.2 + }) + + new_value = 1.2 + old_value = 1.1 + new_last_reset = None + old_last_reset = None + is_accumulative_value = True + + # Act + result = add_consumption(current, + tracked_consumption_data, + untracked_consumption_data, + new_value, + old_value, + new_last_reset, + old_last_reset, + is_accumulative_value, + is_tracking, + is_manual_reset_enabled=True) + + # Assert + assert result is not None + + if is_tracking: + assert len(result.untracked_consumption_data) == 0 + + assert len(result.tracked_consumption_data) == 2 + assert result.tracked_consumption_data[0]["start"] == tracked_consumption_data[0]["start"] + assert result.tracked_consumption_data[0]["end"] == tracked_consumption_data[0]["end"] + assert round(result.tracked_consumption_data[0]["consumption"], 8) == round(tracked_consumption_data[0]["consumption"], 8) + + assert result.tracked_consumption_data[1]["start"] == expected_start + assert result.tracked_consumption_data[1]["end"] == expected_end + assert round(result.tracked_consumption_data[1]["consumption"], 8) == round(0.1, 8) + else: + assert len(result.tracked_consumption_data) == 0 + + assert len(result.untracked_consumption_data) == 2 + assert result.untracked_consumption_data[0]["start"] == untracked_consumption_data[0]["start"] + assert result.untracked_consumption_data[0]["end"] == untracked_consumption_data[0]["end"] + assert round(result.untracked_consumption_data[0]["consumption"], 8) == round(untracked_consumption_data[0]["consumption"], 8) + + assert result.untracked_consumption_data[1]["start"] == expected_start + assert result.untracked_consumption_data[1]["end"] == expected_end + assert round(result.untracked_consumption_data[1]["consumption"], 8) == round(0.1, 8) + @pytest.mark.asyncio @pytest.mark.parametrize("new_value,old_value", [(None, None), (0, None), From 0d67ee6d6bb9a8f2d3532e6f8547b2159c331410 Mon Sep 17 00:00:00 2001 From: BottlecapDave Date: Wed, 1 Jan 2025 06:25:02 +0000 Subject: [PATCH 17/18] chore: Added comment --- .../octopus_energy/api_client_home_pro/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/custom_components/octopus_energy/api_client_home_pro/__init__.py b/custom_components/octopus_energy/api_client_home_pro/__init__.py index 506085d0..28a252f0 100644 --- a/custom_components/octopus_energy/api_client_home_pro/__init__.py +++ b/custom_components/octopus_energy/api_client_home_pro/__init__.py @@ -73,6 +73,8 @@ async def async_get_consumption(self, is_electricity: bool) -> list | None: divisor = int(data["raw"]["divisor"], 16) return [{ "total_consumption": int(data["consumption"]) / divisor if divisor > 0 else None, + # Base divisor is 1000, but reports of it being 10000 which represent a factor of 10 out in the reported value. Therefore we are using + # 1000 as our baseline for our divisor so our demand is still reported in watts https://forum.octopus.energy/t/for-the-pro-user/8453/2892 "demand": float(data["instdmand"]) / (divisor / 1000) if divisor > 0 and "instdmand" in data else None, "start": datetime.fromtimestamp(int(meter_consump["time"]), timezone.utc), "end": datetime.fromtimestamp(int(meter_consump["time"]), timezone.utc), From e7ab8ff6f167b5afd5fbbba6b53f0ddd5870fbd5 Mon Sep 17 00:00:00 2001 From: Lewis Juggins <873275+lwis@users.noreply.github.com> Date: Wed, 1 Jan 2025 10:55:42 +0000 Subject: [PATCH 18/18] Fix comment --- custom_components/octopus_energy/heat_pump/zone.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/octopus_energy/heat_pump/zone.py b/custom_components/octopus_energy/heat_pump/zone.py index a3dc1058..19e7259d 100644 --- a/custom_components/octopus_energy/heat_pump/zone.py +++ b/custom_components/octopus_energy/heat_pump/zone.py @@ -253,7 +253,7 @@ async def async_set_heat_pump_flow_temp_config(self, weather_comp_enabled: bool, await self._client.async_set_heat_pump_flow_temp_config(self._heat_pump_id, weather_comp_enabled, weather_comp_min_temperature, weather_comp_max_temperature, fixed_flow_temperature) except Exception as e: if self._is_mocked: - _LOGGER.warning(f'Suppress async_turn_on error due to mocking mode: {e}') + _LOGGER.warning(f'Suppress async_set_heat_pump_flow_temp_config error due to mocking mode: {e}') else: raise