Skip to content

Commit bd44266

Browse files
Feat/water heater heat pump (#1367)
2 parents 488c3bf + d046834 commit bd44266

File tree

6 files changed

+340
-6
lines changed

6 files changed

+340
-6
lines changed

_docs/entities/heat_pump.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,30 @@ This represents the temperature reported by a sensor (e.g. Cosy Pod) that is ass
2424

2525
`climate.octopus_energy_heat_pump_{{HEAT_PUMP_ID}}_{{ZONE_CODE}}`
2626

27-
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.
27+
This can be used to control the target temperature and mode for a given zone (e.g. zone 1) linked to your heat pump. It will also display the current temperature linked to the primary sensor for the zone.
2828

2929
The following operation modes are available
3030

3131
* `Heat` - This represents as `on` in the app
3232
* `Off` - This represents as `off` in the app
3333
* `Auto` - This represents as `auto` in the app
3434

35-
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).
35+
In addition, there is the preset of `boost`. When `boost` is selected, this activates boost mode for the zone for 1 hour. If a target temperature is not set, then this will default to 50 degrees c. If you require boost to be on for a different amount of time or with a different target temperature, then you can use the [available service](../services.md#octopus_energyboost_heat_pump_zone).
36+
37+
## Water Heater
38+
39+
`water_heater.octopus_energy_heat_pump_{{HEAT_PUMP_ID}}`
40+
41+
This can be used to control the target temperature and mode for a given water heater linked to your heat pump. It will also display the current temperature linked to the primary sensor for the zone.
42+
43+
The following operation modes are available
44+
45+
* `on` - This represents as `on` in the app
46+
* `off` - This represents as `off` in the app
47+
* `heat_pump` - This represents as `auto` in the app
48+
* `high_demand` - This represents as `boost` in the app
49+
50+
When `boost` is selected, this activates boost mode for the zone for 1 hour. If a target temperature is not set, then this will default to 50 degrees c. If you require boost to be on for a different amount of time or with a different target temperature, then you can use the [available service](../services.md#octopus_energyboost_heat_pump_zone).
3651

3752
!!! note
3853

custom_components/octopus_energy/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@
8585
REPAIR_UNKNOWN_INTELLIGENT_PROVIDER
8686
)
8787

88-
ACCOUNT_PLATFORMS = ["sensor", "binary_sensor", "number", "switch", "text", "time", "event", "select", "climate"]
88+
ACCOUNT_PLATFORMS = ["sensor", "binary_sensor", "number", "switch", "text", "time", "event", "select", "climate", "water_heater"]
8989
TARGET_RATE_PLATFORMS = ["binary_sensor"]
9090
COST_TRACKER_PLATFORMS = ["sensor"]
9191
TARIFF_COMPARISON_PLATFORMS = ["sensor"]

custom_components/octopus_energy/climate.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ def setup_heat_pump_sensors(hass: HomeAssistant, client: OctopusEnergyApiClient,
102102
if heat_pump_response is not None and heat_pump_response.octoHeatPumpControllerConfiguration is not None:
103103
for zone in heat_pump_response.octoHeatPumpControllerConfiguration.zones:
104104
if zone.configuration is not None:
105-
if zone.configuration.enabled == False:
105+
if zone.configuration.enabled == False or zone.configuration.zoneType == "WATER":
106106
continue
107107

108108
entities.append(OctopusEnergyHeatPumpZone(
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
from datetime import datetime, timedelta
2+
import logging
3+
from typing import List
4+
5+
from homeassistant.util.dt import (utcnow)
6+
from homeassistant.exceptions import ServiceValidationError
7+
8+
from homeassistant.const import (
9+
UnitOfTemperature,
10+
PRECISION_HALVES,
11+
ATTR_TEMPERATURE,
12+
STATE_OFF,
13+
STATE_ON
14+
)
15+
from homeassistant.core import HomeAssistant, callback
16+
17+
from homeassistant.util.dt import (now)
18+
from homeassistant.helpers.update_coordinator import (
19+
CoordinatorEntity
20+
)
21+
from homeassistant.components.water_heater import (
22+
WaterHeaterEntity,
23+
WaterHeaterEntityFeature,
24+
STATE_HEAT_PUMP,
25+
STATE_HIGH_DEMAND
26+
)
27+
28+
from .base import (BaseOctopusEnergyHeatPumpSensor)
29+
from ..utils.attributes import dict_to_typed_dict
30+
from ..api_client.heat_pump import ConfigurationZone, HeatPump, Sensor, Zone
31+
from ..coordinators.heat_pump_configuration_and_status import HeatPumpCoordinatorResult
32+
from ..api_client import OctopusEnergyApiClient
33+
from ..const import DEFAULT_BOOST_TEMPERATURE_WATER, DOMAIN
34+
35+
_LOGGER = logging.getLogger(__name__)
36+
37+
OE_TO_HA_STATE = {
38+
"AUTO": STATE_HEAT_PUMP,
39+
"BOOST": STATE_HIGH_DEMAND,
40+
"ON": STATE_ON,
41+
"OFF": STATE_OFF,
42+
}
43+
44+
HA_TO_OE_STATE = {
45+
STATE_HEAT_PUMP: "AUTO",
46+
STATE_HIGH_DEMAND: "BOOST",
47+
STATE_ON: "ON",
48+
STATE_OFF: "OFF",
49+
}
50+
51+
class OctopusEnergyHeatPumpWaterHeater(CoordinatorEntity, BaseOctopusEnergyHeatPumpSensor, WaterHeaterEntity):
52+
"""Sensor for interacting with a heat pump water heater zone."""
53+
54+
_attr_supported_features = (
55+
WaterHeaterEntityFeature.ON_OFF
56+
| WaterHeaterEntityFeature.TARGET_TEMPERATURE
57+
| WaterHeaterEntityFeature.OPERATION_MODE
58+
)
59+
60+
_attr_operation_list = [STATE_ON, STATE_OFF, STATE_HEAT_PUMP, STATE_HIGH_DEMAND]
61+
_attr_current_operation = None
62+
_attr_temperature_unit = UnitOfTemperature.CELSIUS
63+
_attr_precision = PRECISION_HALVES
64+
65+
def __init__(self, hass: HomeAssistant, coordinator, client: OctopusEnergyApiClient, account_id: str, heat_pump_id: str, heat_pump: HeatPump, zone: ConfigurationZone, is_mocked: bool):
66+
"""Init sensor."""
67+
self._zone = zone
68+
self._account_id = account_id
69+
self._client = client
70+
self._is_mocked = is_mocked
71+
self._end_timestamp = None
72+
73+
self._attr_min_temp = 40
74+
self._attr_max_temp = 60
75+
76+
# Pass coordinator to base class
77+
CoordinatorEntity.__init__(self, coordinator)
78+
BaseOctopusEnergyHeatPumpSensor.__init__(self, hass, heat_pump_id, heat_pump, "water_heater")
79+
80+
self._state = None
81+
self._last_updated = None
82+
83+
@property
84+
def unique_id(self):
85+
"""The id of the sensor."""
86+
return f"octopus_energy_heat_pump_{self._heat_pump_id}"
87+
88+
@property
89+
def name(self):
90+
"""Name of the sensor."""
91+
return f"Zone ({self._zone.configuration.displayName}) Heat Pump ({self._heat_pump_id})"
92+
93+
@callback
94+
def _handle_coordinator_update(self) -> None:
95+
"""Retrieve the previous rate."""
96+
97+
current = now()
98+
result: HeatPumpCoordinatorResult = self.coordinator.data if self.coordinator is not None and self.coordinator.data is not None else None
99+
if (result is not None and
100+
result.data is not None and
101+
result.data.octoHeatPumpControllerStatus is not None and
102+
result.data.octoHeatPumpControllerStatus.zones and
103+
(self._last_updated is None or self._last_updated < result.last_retrieved)):
104+
_LOGGER.debug(f"Updating OctopusEnergyHeatPumpWaterHeater for '{self._heat_pump_id}/{self._zone.configuration.code}'")
105+
106+
zones: List[Zone] = result.data.octoHeatPumpControllerStatus.zones
107+
for zone in zones:
108+
if zone.telemetry is not None and zone.zone == self._zone.configuration.code and zone.telemetry.mode is not None:
109+
110+
if zone.telemetry.mode in OE_TO_HA_STATE:
111+
self._attr_current_operation = OE_TO_HA_STATE[zone.telemetry.mode]
112+
else:
113+
raise Exception(f"Unexpected heat pump mode detected: {zone.telemetry.mode}")
114+
115+
self._attr_target_temperature = None
116+
if zone.telemetry.setpointInCelsius is not None and zone.telemetry.setpointInCelsius > 0:
117+
self._attr_target_temperature = zone.telemetry.setpointInCelsius
118+
119+
if result.data.octoHeatPumpControllerStatus.sensors and self._zone.configuration.primarySensor:
120+
sensors: List[Sensor] = result.data.octoHeatPumpControllerStatus.sensors
121+
for sensor in sensors:
122+
if sensor.code == self._zone.configuration.primarySensor and sensor.telemetry is not None:
123+
self._attr_current_temperature = sensor.telemetry.temperatureInCelsius
124+
self._attr_current_humidity = sensor.telemetry.humidityPercentage
125+
126+
if result.data.octoHeatPumpControllerConfiguration is not None and result.data.octoHeatPumpControllerConfiguration.zones:
127+
configs: List[ConfigurationZone] = result.data.octoHeatPumpControllerConfiguration.zones
128+
for config in configs:
129+
if config.configuration is not None and config.configuration.code == self._zone.configuration.code and config.configuration.currentOperation is not None:
130+
self._end_timestamp = datetime.fromisoformat(config.configuration.currentOperation.end) if config.configuration.currentOperation.end is not None else None
131+
132+
self._last_updated = current
133+
134+
self._attributes = dict_to_typed_dict(self._attributes)
135+
super()._handle_coordinator_update()
136+
137+
async def async_set_operation_mode(self, operation_mode: str):
138+
"""Set new target operation mode."""
139+
try:
140+
self._attr_current_operation = operation_mode
141+
142+
if OE_TO_HA_STATE["BOOST"] == self._attr_current_operation:
143+
self._end_timestamp = utcnow()
144+
self._end_timestamp += timedelta(hours=1)
145+
await self._client.async_boost_heat_pump_zone(
146+
self._account_id,
147+
self._heat_pump_id,
148+
self._zone.configuration.code,
149+
self._end_timestamp,
150+
self._attr_target_temperature if self._attr_target_temperature is not None else DEFAULT_BOOST_TEMPERATURE_WATER
151+
)
152+
else:
153+
zone_mode = self.get_zone_mode()
154+
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)
155+
except Exception as e:
156+
if self._is_mocked:
157+
_LOGGER.warning(f'Suppress async_set_preset_mode error due to mocking mode: {e}')
158+
else:
159+
raise
160+
161+
self.async_write_ha_state()
162+
163+
async def async_turn_on(self):
164+
"""Turn the entity on."""
165+
try:
166+
self._attr_current_operation = OE_TO_HA_STATE["ON"]
167+
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)
168+
except Exception as e:
169+
if self._is_mocked:
170+
_LOGGER.warning(f'Suppress async_turn_on error due to mocking mode: {e}')
171+
else:
172+
raise
173+
174+
self.async_write_ha_state()
175+
176+
async def async_turn_off(self):
177+
"""Turn the entity off."""
178+
try:
179+
self._attr_current_operation = OE_TO_HA_STATE["OFF"]
180+
await self._client.async_set_heat_pump_zone_mode(self._account_id, self._heat_pump_id, self._zone.configuration.code, 'OFF', None)
181+
except Exception as e:
182+
if self._is_mocked:
183+
_LOGGER.warning(f'Suppress async_turn_off error due to mocking mode: {e}')
184+
else:
185+
raise
186+
187+
self.async_write_ha_state()
188+
189+
async def async_set_temperature(self, **kwargs) -> None:
190+
"""Set new target temperature."""
191+
192+
try:
193+
self._attr_target_temperature = kwargs[ATTR_TEMPERATURE]
194+
if OE_TO_HA_STATE["BOOST"] == self._attr_current_operation:
195+
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)
196+
else:
197+
zone_mode = self.get_zone_mode()
198+
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)
199+
except Exception as e:
200+
if self._is_mocked:
201+
_LOGGER.warning(f'Suppress async_set_temperature error due to mocking mode: {e}')
202+
else:
203+
raise
204+
205+
self.async_write_ha_state()
206+
207+
@callback
208+
async def async_boost_heat_pump_zone(self, hours: int, minutes: int, target_temperature: float | None = None):
209+
"""Boost the heat pump zone"""
210+
211+
if target_temperature is not None:
212+
if target_temperature < self._attr_min_temp or target_temperature > self._attr_max_temp:
213+
raise ServiceValidationError(
214+
translation_domain=DOMAIN,
215+
translation_key="invalid_target_temperature",
216+
translation_placeholders={
217+
"min_temperature": self._attr_min_temp,
218+
"max_temperature": self._attr_max_temp
219+
},
220+
)
221+
222+
self._end_timestamp = utcnow()
223+
self._end_timestamp += timedelta(hours=hours, minutes=minutes)
224+
self._attr_current_operation = OE_TO_HA_STATE["BOOST"]
225+
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)
226+
227+
self.async_write_ha_state()
228+
229+
def get_zone_mode(self):
230+
if self._attr_current_operation in HA_TO_OE_STATE:
231+
return HA_TO_OE_STATE[self._attr_current_operation]
232+
else:
233+
raise Exception(f"Unexpected heat pump mode detected: {self._attr_current_operation}")

custom_components/octopus_energy/heat_pump/zone.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
from homeassistant.const import (
99
UnitOfTemperature,
1010
PRECISION_HALVES,
11-
PRECISION_TENTHS,
1211
ATTR_TEMPERATURE
1312
)
1413
from homeassistant.core import HomeAssistant, callback
@@ -52,7 +51,6 @@ class OctopusEnergyHeatPumpZone(CoordinatorEntity, BaseOctopusEnergyHeatPumpSens
5251
_attr_preset_modes = [PRESET_NONE, PRESET_BOOST]
5352
_attr_preset_mode = None
5453
_attr_temperature_unit = UnitOfTemperature.CELSIUS
55-
_attr_target_temperature_step = PRECISION_TENTHS
5654
_attr_target_temperature_step = PRECISION_HALVES
5755

5856
def __init__(self, hass: HomeAssistant, coordinator, client: OctopusEnergyApiClient, account_id: str, heat_pump_id: str, heat_pump: HeatPump, zone: ConfigurationZone, is_mocked: bool):
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import logging
2+
3+
from custom_components.octopus_energy.heat_pump.water_heater import OctopusEnergyHeatPumpWaterHeater
4+
import voluptuous as vol
5+
6+
from homeassistant.core import HomeAssistant
7+
from homeassistant.helpers import entity_platform
8+
import homeassistant.helpers.config_validation as cv
9+
10+
from .api_client.heat_pump import HeatPumpResponse
11+
from .heat_pump import get_mock_heat_pump_id
12+
from .heat_pump.zone import OctopusEnergyHeatPumpZone
13+
from .utils.debug_overrides import async_get_account_debug_override
14+
15+
from .const import (
16+
CONFIG_ACCOUNT_ID,
17+
DATA_ACCOUNT,
18+
DATA_CLIENT,
19+
DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_COORDINATOR,
20+
DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_KEY,
21+
DOMAIN,
22+
23+
CONFIG_MAIN_API_KEY
24+
)
25+
from .api_client import OctopusEnergyApiClient
26+
27+
_LOGGER = logging.getLogger(__name__)
28+
29+
async def async_setup_entry(hass, entry, async_add_entities):
30+
"""Setup sensors based on our entry"""
31+
32+
config = dict(entry.data)
33+
34+
if entry.options:
35+
config.update(entry.options)
36+
37+
if CONFIG_MAIN_API_KEY in config:
38+
await async_setup_default_sensors(hass, config, async_add_entities)
39+
40+
return True
41+
42+
async def async_setup_default_sensors(hass, config, async_add_entities):
43+
_LOGGER.debug('Setting up default sensors')
44+
45+
entities = []
46+
47+
account_id = config[CONFIG_ACCOUNT_ID]
48+
client = hass.data[DOMAIN][account_id][DATA_CLIENT]
49+
account_debug_override = await async_get_account_debug_override(hass, account_id)
50+
account_result = hass.data[DOMAIN][account_id][DATA_ACCOUNT]
51+
account_info = account_result.account if account_result is not None else None
52+
53+
mock_heat_pump = account_debug_override.mock_heat_pump if account_debug_override is not None else False
54+
if mock_heat_pump:
55+
heat_pump_id = get_mock_heat_pump_id()
56+
key = DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_KEY.format(heat_pump_id)
57+
coordinator = hass.data[DOMAIN][account_id][DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_COORDINATOR.format(heat_pump_id)]
58+
entities.extend(setup_heat_pump_sensors(hass, client, account_id, heat_pump_id, hass.data[DOMAIN][account_id][key].data, coordinator, mock_heat_pump))
59+
elif "heat_pump_ids" in account_info:
60+
for heat_pump_id in account_info["heat_pump_ids"]:
61+
key = DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_KEY.format(heat_pump_id)
62+
coordinator = hass.data[DOMAIN][account_id][DATA_HEAT_PUMP_CONFIGURATION_AND_STATUS_COORDINATOR.format(heat_pump_id)]
63+
entities.extend(setup_heat_pump_sensors(hass, client, account_id, heat_pump_id, hass.data[DOMAIN][account_id][key].data, coordinator, mock_heat_pump))
64+
65+
async_add_entities(entities)
66+
67+
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):
68+
69+
entities = []
70+
71+
if heat_pump_response is not None and heat_pump_response.octoHeatPumpControllerConfiguration is not None:
72+
for zone in heat_pump_response.octoHeatPumpControllerConfiguration.zones:
73+
if zone.configuration is not None:
74+
if zone.configuration.enabled == False or zone.configuration.zoneType != "WATER":
75+
continue
76+
77+
entities.append(OctopusEnergyHeatPumpWaterHeater(
78+
hass,
79+
coordinator,
80+
client,
81+
account_id,
82+
heat_pump_id,
83+
heat_pump_response.octoHeatPumpControllerConfiguration.heatPump,
84+
zone,
85+
mock_heat_pump
86+
))
87+
88+
return entities

0 commit comments

Comments
 (0)