diff --git a/custom_components/octopus_energy/api_client/__init__.py b/custom_components/octopus_energy/api_client/__init__.py index 71d1ec08..387ffc4f 100644 --- a/custom_components/octopus_energy/api_client/__init__.py +++ b/custom_components/octopus_energy/api_client/__init__.py @@ -371,6 +371,7 @@ octoHeatPumpSetZoneMode(accountNumber: "{account_id}", euid: "{euid}", operationParameters: {{ zone: {zone_id}, mode: BOOST, + setpointInCelsius: "{target_temperature}", endAt: "{end_at}" }}) {{ transactionId @@ -824,14 +825,14 @@ async def async_set_heat_pump_zone_mode(self, account_id: str, euid: str, zone_i _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): + 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")) + 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: diff --git a/custom_components/octopus_energy/climate.py b/custom_components/octopus_energy/climate.py index f4cd6bb3..786388e9 100644 --- a/custom_components/octopus_energy/climate.py +++ b/custom_components/octopus_energy/climate.py @@ -49,6 +49,7 @@ async def async_setup_default_sensors(hass, config, async_add_entities): { vol.Required("hours"): cv.positive_int, vol.Required("minutes"): cv.positive_int, + vol.Optional("target_temperature"): cv.positive_float, }, extra=vol.ALLOW_EXTRA, ), diff --git a/custom_components/octopus_energy/heat_pump/zone.py b/custom_components/octopus_energy/heat_pump/zone.py index 8f6aab02..0e147e78 100644 --- a/custom_components/octopus_energy/heat_pump/zone.py +++ b/custom_components/octopus_energy/heat_pump/zone.py @@ -2,10 +2,13 @@ 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 ) @@ -47,6 +50,7 @@ class OctopusEnergyHeatPumpZone(CoordinatorEntity, BaseOctopusEnergyHeatPumpSens _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.""" @@ -140,20 +144,21 @@ def _handle_coordinator_update(self) -> None: async def async_set_hvac_mode(self, hvac_mode): """Set new target hvac mode.""" try: - await self._client.async_set_heat_pump_zone_mode(self._account_id, self._heat_pump_id, self._zone.configuration.code, hvac_mode, None) + self._attr_hvac_mode = hvac_mode + 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._attr_hvac_mode = hvac_mode self.async_write_ha_state() async def async_turn_on(self): """Turn the entity on.""" try: - await self._client.async_set_heat_pump_zone_mode(self._account_id, self._heat_pump_id, self._zone.configuration.code, 'ON', 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}') @@ -179,38 +184,29 @@ async def async_turn_off(self): async def async_set_preset_mode(self, preset_mode): """Set new target preset mode.""" try: - if preset_mode == PRESET_BOOST: + self._attr_preset_mode = preset_mode + + if self._attr_preset_mode == PRESET_BOOST: current = utcnow() current += timedelta(hours=1) - await self._client.async_boost_heat_pump_zone(self._account_id, self._heat_pump_id, self._zone.configuration.code, current) + await self._client.async_boost_heat_pump_zone(self._account_id, self._heat_pump_id, self._zone.configuration.code, current, self._attr_target_temperature) else: - await self._client.async_set_heat_pump_zone_mode(self._account_id, self._heat_pump_id, self._zone.configuration.code, self._attr_hvac_mode, 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_preset_mode error due to mocking mode: {e}') else: raise - self._attr_preset_mode = preset_mode self.async_write_ha_state() async def async_set_temperature(self, **kwargs) -> None: """Set new target temperature.""" temperature = kwargs[ATTR_TEMPERATURE] - zone_mode = None - if self._attr_preset_mode == PRESET_BOOST: - zone_mode = "BOOST" - elif self._attr_hvac_mode == HVACMode.HEAT: - zone_mode = "ON" - elif self._attr_hvac_mode == HVACMode.OFF: - zone_mode = "OFF" - elif self._attr_hvac_mode == HVACMode.AUTO: - zone_mode = "AUTO" - else: - raise Exception(f"Unexpected heat pump mode detected: {self._attr_hvac_mode}/{self._attr_preset_mode}") - try: + 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, temperature) except Exception as e: if self._is_mocked: @@ -221,13 +217,36 @@ async def async_set_temperature(self, **kwargs) -> None: self._attr_target_temperature = 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}") + @callback - async def async_boost_heat_pump_zone(self, hours: int, minutes: int): + 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 + }, + ) + current = utcnow() current += timedelta(hours=hours, minutes=minutes) - await self._client.async_boost_heat_pump_zone(self._account_id, self._heat_pump_id, self._zone.configuration.code, current) + await self._client.async_boost_heat_pump_zone(self._account_id, self._heat_pump_id, self._zone.configuration.code, current, target_temperature if target_temperature is not None else self._attr_target_temperature) self._attr_preset_mode = PRESET_BOOST self.async_write_ha_state() \ No newline at end of file diff --git a/custom_components/octopus_energy/services.yaml b/custom_components/octopus_energy/services.yaml index 0f15374c..fc85d36a 100644 --- a/custom_components/octopus_energy/services.yaml +++ b/custom_components/octopus_energy/services.yaml @@ -259,4 +259,12 @@ boost_heat_pump_zone: 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/translations/en.json b/custom_components/octopus_energy/translations/en.json index bce3a406..c84ca671 100644 --- a/custom_components/octopus_energy/translations/en.json +++ b/custom_components/octopus_energy/translations/en.json @@ -275,6 +275,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}" } }, "issues": {