I made a crappy Nest Thermostat integration that will work as a stopgap until alternatives come out

Thanks again @Zerovici for getting this working, much like you I only use badnest to boost the hot water for 30 mins or 60 mins manually using buttons on my main dashboard, when on these then are replaced with a cancel boost button which is why I noticed the status not working. I rarely use the cancel boost unless I accidentally boosted the water so not a big deal and can always use the Nest app. I also intended to expose the boost to Alexa as well so it can be boosted with voice command (as this isn’t supported through native Nest / Alexa integration) which should still work with your fix.

For the main Nest thermostat control I use the main Nest integration which works well (I think that’s the one you have to pay a small fee to Google for access to the API? it was a long time ago I did this). I also have Nest Protect custom add-in as well which seems to work well and uses a similar method as Badnest to communicate with Google/Nest.

I think I was able to get a little more working with status of ‘on’ at least, but I need to test again with your original code edit to remind myself as I fiddled quite a bit.

I saw this as a good exercise to try and understand some Python and using GitHub Copilot as well hence trying to reverse engineer how this all works, I might need to start with something simpler though :slight_smile:

Okay, with a bit of time, a fresher head this morning and it snowing outside - I figured I’d try this again. Leaving out ChatGPT (it’s not always right!) I’ve got to the stage where Nest Thermostat, Nest Protect and Hot Water working again. I’ve checked the functionality, all looks good - but please let me know if you spot anything… hope this helps others.

climate.py

from datetime import datetime
import logging

try:
    from homeassistant.components.climate import ClimateEntity, ClimateEntityFeature
except ImportError:
    from homeassistant.components.climate import ClimateDevice as ClimateEntity
from homeassistant.components.climate.const import (
    ATTR_TARGET_TEMP_HIGH,
    ATTR_TARGET_TEMP_LOW,
    FAN_AUTO,
    FAN_ON,
    HVACMode,
    PRESET_ECO,
    PRESET_NONE,
)
from homeassistant.const import (
    ATTR_TEMPERATURE,
    UnitOfTemperature,
)

from .const import (
    DOMAIN,
)

NEST_MODE_HEAT_COOL = "range"
NEST_MODE_ECO = "eco"
NEST_MODE_HEAT = "heat"
NEST_MODE_COOL = "cool"
NEST_MODE_OFF = "off"

MODE_HASS_TO_NEST = {
    HVACMode.HEAT_COOL: NEST_MODE_HEAT_COOL,
    HVACMode.HEAT: NEST_MODE_HEAT,
    HVACMode.COOL: NEST_MODE_COOL,
    HVACMode.OFF: NEST_MODE_OFF,
}

ACTION_NEST_TO_HASS = {
    "off": HVACMode.OFF,
    "heating": HVACMode.HEAT,
    "cooling": HVACMode.COOL,
}

MODE_NEST_TO_HASS = {v: k for k, v in MODE_HASS_TO_NEST.items()}

ROUND_TARGET_HUMIDITY_TO_NEAREST = 5
NEST_HUMIDITY_MIN = 10
NEST_HUMIDITY_MAX = 60

PRESET_MODES = [PRESET_NONE, PRESET_ECO]

_LOGGER = logging.getLogger(__name__)


async def async_setup_platform(hass,
                               config,
                               async_add_entities,
                               discovery_info=None):
    """Set up the Nest climate device."""
    api = hass.data[DOMAIN]['api']

    thermostats = []
    _LOGGER.info("Adding thermostats")
    for thermostat in api['thermostats']:
        _LOGGER.info(f"Adding nest thermostat uuid: {thermostat}")
        thermostats.append(NestClimate(thermostat, api))

    async_add_entities(thermostats)


class NestClimate(ClimateEntity):

    """Representation of a Nest climate entity."""

    def __init__(self, device_id, api):
        """Initialize the thermostat."""
        self._name = "Nest Thermostat"
        self._unit_of_measurement = UnitOfTemperature.CELSIUS
        self._fan_modes = [FAN_ON, FAN_AUTO]
        self.device_id = device_id

        # Set the default supported features
        self._support_flags = ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE

        # Not all nest devices support cooling and heating remove unused
        self._operation_list = []

        self.device = api

        if self.device.device_data[device_id]['can_heat'] \
                and self.device.device_data[device_id]['can_cool']:
            self._operation_list.append(HVACMode.HEAT_COOL)
            self._support_flags |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE

        # Add supported nest thermostat features
        if self.device.device_data[device_id]['can_heat']:
            self._operation_list.append(HVACMode.HEAT)

        if self.device.device_data[device_id]['can_cool']:
            self._operation_list.append(HVACMode.COOL)

        self._operation_list.append(HVACMode.OFF)

        # feature of device
        if self.device.device_data[device_id]['has_fan']:
            self._support_flags = self._support_flags | ClimateEntityFeature.FAN_MODE

        if self.device.device_data[device_id]['target_humidity_enabled']:
            self._support_flags = self._support_flags | ClimateEntityFeature.TARGET_HUMIDITY

    @property
    def unique_id(self):
        """Return an unique ID."""
        return self.device_id

    @property
    def name(self):
        """Return an friendly name."""
        return self.device.device_data[self.device_id]['name']

    @property
    def supported_features(self):
        """Return the list of supported features."""
        return self._support_flags

    @property
    def should_poll(self):
        """Return the polling state."""
        return True

    @property
    def temperature_unit(self):
        """Return the unit of measurement."""
        return self._unit_of_measurement

    @property
    def current_temperature(self):
        """Return the current temperature."""
        return self.device.device_data[self.device_id]['current_temperature']

    @property
    def current_humidity(self):
        """Return the current humidity."""
        return self.device.device_data[self.device_id]['current_humidity']

    @property
    def target_humidity(self):
        """Return the target humidity."""
        return self.device.device_data[self.device_id]['target_humidity']

    @property
    def min_humidity(self):
        """Return the min target humidity."""
        return NEST_HUMIDITY_MIN

    @property
    def max_humidity(self):
        """Return the max target humidity."""
        return NEST_HUMIDITY_MAX

    @property
    def target_temperature(self):
        """Return the temperature we try to reach."""
        if self.device.device_data[self.device_id]['mode'] \
                != NEST_MODE_HEAT_COOL \
                and not self.device.device_data[self.device_id]['eco']:
            return \
                self.device.device_data[self.device_id]['target_temperature']
        return None

    @property
    def target_temperature_high(self):
        """Return the highbound target temperature we try to reach."""
        if self.device.device_data[self.device_id]['mode'] \
                == NEST_MODE_HEAT_COOL \
                and not self.device.device_data[self.device_id]['eco']:
            return \
                self.device. \
                device_data[self.device_id]['target_temperature_high']
        return None

    @property
    def target_temperature_low(self):
        """Return the lowbound target temperature we try to reach."""
        if self.device.device_data[self.device_id]['mode'] \
                == NEST_MODE_HEAT_COOL \
                and not self.device.device_data[self.device_id]['eco']:
            return \
                self.device. \
                device_data[self.device_id]['target_temperature_low']
        return None

    @property
    def hvac_action(self):
        """Return current operation ie. heat, cool, idle."""
        return ACTION_NEST_TO_HASS[
            self.device.device_data[self.device_id]['action']
        ]

    @property
    def hvac_mode(self):
        """Return hvac target hvac state."""
        if self.device.device_data[self.device_id]['mode'] is None \
                or self.device.device_data[self.device_id]['eco']:
            # We assume the first operation in operation list is the main one
            return self._operation_list[0]

        return self.device.device_data[self.device_id]['mode']

    @property
    def hvac_modes(self):
        """Return the list of available operation modes."""
        return self._operation_list

    @property
    def preset_mode(self):
        """Return current preset mode."""
        if self.device.device_data[self.device_id]['eco']:
            return PRESET_ECO

        return PRESET_NONE

    @property
    def preset_modes(self):
        """Return preset modes."""
        return PRESET_MODES

    @property
    def fan_mode(self):
        """Return whether the fan is on."""
        if self.device.device_data[self.device_id]['has_fan']:
            # Return whether the fan is on
            if self.device.device_data[self.device_id]['fan']:
                return FAN_ON
            else:
                return FAN_AUTO
        # No Fan available so disable slider
        return None

    @property
    def fan_modes(self):
        """Return the list of available fan modes."""
        if self.device.device_data[self.device_id]['has_fan']:
            return self._fan_modes
        return None

    def set_temperature(self, **kwargs):
        """Set new target temperature."""
        temp = None
        target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW)
        target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH)
        if self.device.device_data[self.device_id]['mode'] == \
                NEST_MODE_HEAT_COOL:
            if target_temp_low is not None and target_temp_high is not None:
                self.device.thermostat_set_temperature(
                    self.device_id,
                    target_temp_low,
                    target_temp_high,
                )
        else:
            temp = kwargs.get(ATTR_TEMPERATURE)
            if temp is not None:
                self.device.thermostat_set_temperature(
                    self.device_id,
                    temp,
                )

    def set_humidity(self, humidity):
        """Set new target humidity."""
        humidity = int(round(float(humidity) / ROUND_TARGET_HUMIDITY_TO_NEAREST) * ROUND_TARGET_HUMIDITY_TO_NEAREST)
        if humidity < NEST_HUMIDITY_MIN:
            humidity = NEST_HUMIDITY_MIN
        if humidity > NEST_HUMIDITY_MAX:
            humidity = NEST_HUMIDITY_MAX
        self.device.thermostat_set_target_humidity(
            self.device_id,
            humidity,
        )

    def set_hvac_mode(self, hvac_mode):
        """Set operation mode."""
        self.device.thermostat_set_mode(
            self.device_id,
            MODE_HASS_TO_NEST[hvac_mode],
        )

    def set_fan_mode(self, fan_mode):
        """Turn fan on/off."""
        if self.device.device_data[self.device_id]['has_fan']:
            if fan_mode == "on":
                self.device.thermostat_set_fan(
                    self.device_id,
                    int(datetime.now().timestamp() + 60 * 30),
                )
            else:
                self.device.thermostat_set_fan(
                    self.device_id,
                    0,
                )

    def set_preset_mode(self, preset_mode):
        """Set preset mode."""
        need_eco = preset_mode == PRESET_ECO

        if need_eco != self.device.device_data[self.device_id]['eco']:
            self.device.thermostat_set_eco_mode(
                self.device_id,
                need_eco,
            )

    def update(self):
        """Updates data."""
        self.device.update()

sensor.py

import logging

from homeassistant.helpers.entity import Entity

from homeassistant.components.sensor import SensorEntity, SensorDeviceClass

from .const import DOMAIN

from homeassistant.const import (
    ATTR_BATTERY_LEVEL,
    UnitOfTemperature
)

_LOGGER = logging.getLogger(__name__)

PROTECT_SENSOR_TYPES = [
    "co_status",
    "smoke_status",
    "battery_health_state"
]


async def async_setup_platform(hass,
                               config,
                               async_add_entities,
                               discovery_info=None):
    """Set up the Nest climate device."""
    api = hass.data[DOMAIN]['api']

    temperature_sensors = []
    _LOGGER.info("Adding temperature sensors")
    for sensor in api['temperature_sensors']:
        _LOGGER.info(f"Adding nest temp sensor uuid: {sensor}")
        temperature_sensors.append(NestTemperatureSensor(sensor, api))

    async_add_entities(temperature_sensors)

    protect_sensors = []
    _LOGGER.info("Adding protect sensors")
    for sensor in api['protects']:
        _LOGGER.info(f"Adding nest protect sensor uuid: {sensor}")
        for sensor_type in PROTECT_SENSOR_TYPES:
            protect_sensors.append(NestProtectSensor(sensor, sensor_type, api))

    async_add_entities(protect_sensors)


class NestTemperatureSensor(Entity):

    """Implementation of the Nest Temperature Sensor."""

    def __init__(self, device_id, api):
        """Initialize the sensor."""
        self._name = "Nest Temperature Sensor"
        self._unit_of_measurement = UnitOfTemperature.CELSIUS
        self.device_id = device_id
        self.device = api

    @property
    def unique_id(self):
        """Return an unique ID."""
        return self.device_id

    @property
    def name(self):
        """Return the name of the sensor."""
        return self.device.device_data[self.device_id]['name']

    @property
    def state(self):
        """Return the state of the sensor."""
        return self.device.device_data[self.device_id]['temperature']

    @property
    def device_class(self):
        """Return the device class of this entity."""
        return SensorDeviceClass.TEMPERATURE

    @property
    def native_unit_of_measurement(self):
        """Return the unit of measurement of this entity, if any."""
        return  self._unit_of_measurement

    def update(self):
        """Get the latest data from the DHT and updates the states."""
        self.device.update()

    @property
    def device_state_attributes(self):
        """Return the state attributes."""
        return {
            ATTR_BATTERY_LEVEL:
                self.device.device_data[self.device_id]['battery_level']
        }


class NestProtectSensor(Entity):

    """Implementation of the Nest Protect sensor."""

    def __init__(self, device_id, sensor_type, api):
        """Initialize the sensor."""
        self._name = "Nest Protect Sensor"
        self.device_id = device_id
        self._sensor_type = sensor_type
        self.device = api

    @property
    def unique_id(self):
        """Return an unique ID."""
        return self.device_id + '_' + self._sensor_type

    @property
    def name(self):
        """Return the name of the sensor."""
        return self.device.device_data[self.device_id]['name'] + \
            f' {self._sensor_type}'

    @property
    def state(self):
        """Return the state of the sensor."""
        return self.device.device_data[self.device_id][self._sensor_type]

    def update(self):
        """Get the latest data from the Protect and updates the states."""
        self.device.update()

water_heater.py

import logging
import time
import voluptuous as vol

from datetime import datetime
from homeassistant.util.dt import now
from homeassistant.helpers import config_validation as cv
from homeassistant.const import (
    ATTR_ENTITY_ID,
    UnitOfTemperature
)
from homeassistant.components.water_heater import (
    STATE_OFF,
    STATE_ON,
    WaterHeaterEntity,
    WaterHeaterEntityFeature,
    ATTR_OPERATION_MODE,
    ATTR_AWAY_MODE
)

from .const import (
    DOMAIN,
)

STATE_SCHEDULE = 'schedule'
SERVICE_BOOST_HOT_WATER = 'boost_hot_water'
ATTR_TIME_PERIOD = 'time_period'
ATTR_BOOST_MODE_STATUS = 'boost_mode_status'
ATTR_BOOST_MODE = 'boost_mode'
ATTR_HEATING_ACTIVE = 'heating_active'
ATTR_AWAY_MODE_ACTIVE = 'away_mode_active'


SUPPORTED_FEATURES = (
    WaterHeaterEntityFeature.OPERATION_MODE |
    WaterHeaterEntityFeature.AWAY_MODE |
    WaterHeaterEntityFeature.ON_OFF
)

NEST_TO_HASS_MODE = {"schedule": STATE_SCHEDULE, "off": STATE_OFF}
HASS_TO_NEST_MODE = {STATE_SCHEDULE: "schedule", STATE_OFF: "off"}
NEST_TO_HASS_STATE = {True: STATE_ON, False: STATE_OFF}
HASS_TO_NEST_STATE = {STATE_ON: True, STATE_OFF: False}
SUPPORTED_OPERATIONS = [STATE_SCHEDULE, STATE_OFF]

BOOST_HOT_WATER_SCHEMA = vol.Schema(
    {
        vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids,
        vol.Optional(ATTR_TIME_PERIOD, default=30): cv.positive_int,
        vol.Required(ATTR_BOOST_MODE): cv.boolean,
    }
)

_LOGGER = logging.getLogger(__name__)


async def async_setup_platform(hass,
                               config,
                               async_add_entities,
                               discovery_info=None):
    """Set up the Nest water heater device."""
    api = hass.data[DOMAIN]['api']

    waterheaters = []
    _LOGGER.info("Adding waterheaters")
    for waterheater in api['hotwatercontrollers']:
        _LOGGER.info(f"Adding nest waterheater uuid: {waterheater}")
        waterheaters.append(NestWaterHeater(waterheater, api))
    async_add_entities(waterheaters)

    def hot_water_boost(service):
        """Handle the service call."""
        entity_ids = service.data[ATTR_ENTITY_ID]
        minutes = service.data[ATTR_TIME_PERIOD]
        timeToEnd = int(time.mktime(datetime.timetuple(now()))+(minutes*60))
        mode = service.data[ATTR_BOOST_MODE]
        _LOGGER.debug('HW boost mode: {} ending: {}'.format(mode, timeToEnd))

        _waterheaters = [
            x for x in waterheaters if not entity_ids or x.entity_id in entity_ids
        ]

        for nest_water_heater in _waterheaters:
            if mode:
                nest_water_heater.turn_boost_mode_on(timeToEnd)
            else:
                nest_water_heater.turn_boost_mode_off()

    hass.services.async_register(
        DOMAIN,
        SERVICE_BOOST_HOT_WATER,
        hot_water_boost,
        schema=BOOST_HOT_WATER_SCHEMA,
    )


class NestWaterHeater(WaterHeaterEntity):
    """Representation of a Nest water heater device."""

    def __init__(self, device_id, api):
        """Initialize the sensor."""
        self._name = "Nest Hot Water Heater"
        self.device_id = device_id
        self.device = api
        self._attr_supported_features = SUPPORTED_FEATURES
        self._attr_operation_list = SUPPORTED_OPERATIONS
        self._attr_temperature_unit = UnitOfTemperature.CELSIUS

    @property
    def unique_id(self):
        """Return an unique ID."""
        return self.device_id + '_hw'

    @property
    def device_info(self):
        """Return device information."""
        return {"identifiers": {(DOMAIN, self.unique_id)}, "name": self.name}

    @property
    def name(self):
        """Return the name of the water heater."""
        return "{0} Hot Water".format(
            self.device.device_data[self.device_id]['name'])

    @property
    def icon(self):
        """Return the icon to use in the frontend."""
        return "mdi:water" if self.current_operation == STATE_SCHEDULE else "mdi:water-off"

    @property
    def state_attributes(self):
        """Return the optional state attributes."""
        data = {}

        supported_features = self.supported_features

        # Operational mode will be off or schedule
        if WaterHeaterEntityFeature.OPERATION_MODE in supported_features:
            data[ATTR_OPERATION_MODE] = self.current_operation

        # This is, is the away mode feature turned on/off
        if WaterHeaterEntityFeature.AWAY_MODE in supported_features:
            is_away = self.is_away_mode_on
            data[ATTR_AWAY_MODE] = STATE_ON if is_away else STATE_OFF

        # away_mode_active - true if away mode is active.
        # If away mode is on, and no one has been seen for 48hrs away mode
        # should go active
        if WaterHeaterEntityFeature.AWAY_MODE in supported_features:
            if self.device.device_data[self.device_id]['hot_water_away_active']:
                away_active = self.device.device_data[self.device_id]['hot_water_away_active']
                data[ATTR_AWAY_MODE_ACTIVE] = away_active
            else:
                data[ATTR_AWAY_MODE_ACTIVE] = False

        if WaterHeaterEntityFeature.ON_OFF in supported_features: # using ON_OFF as a proxy for boost mode
            # boost_mode - true if boost mode is currently active.
            # boost_mode will be 0 if off, and non-zero otherwise - it is set to
            # the epoch time when the boost is due to end
            if self.device.device_data[self.device_id]['hot_water_boost_setting']:
                boost_mode = self.device.device_data[self.device_id]['hot_water_boost_setting']
                data[ATTR_BOOST_MODE_STATUS] = bool(boost_mode)
            else:
                data[ATTR_BOOST_MODE_STATUS] = False

        # heating_active - true if hot water is currently being heated.
        # So it is either on via schedule or boost and currently firing
        # (demand on) the boiler
        if self.device.device_data[self.device_id]['hot_water_status']:
            if self.device.device_data[self.device_id]['hot_water_actively_heating']:
                boiler_firing = self.device.device_data[self.device_id]['hot_water_actively_heating']
                data[ATTR_HEATING_ACTIVE] = boiler_firing
            else:
                data[ATTR_HEATING_ACTIVE] = False
        else:
            data[ATTR_HEATING_ACTIVE] = False

        _LOGGER.debug("Device state attributes: {}".format(data))
        return data

    @property
    def current_operation(self):
        """Return current operation ie. eco, electric, performance, ..."""
        return NEST_TO_HASS_MODE[self.device.device_data[self.device_id]['hot_water_timer_mode']]

    @property
    def is_away_mode_on(self):
        """Return true if away mode is on."""
        away = self.device.device_data[self.device_id]['hot_water_away_setting']
        return away

    def set_operation_mode(self, operation_mode):
        """Set new target operation mode."""
        if self.device.device_data[self.device_id]['has_hot_water_control']:
            self.device.hotwater_set_mode(self.device_id, mode=operation_mode)

    async def async_set_operation_mode(self, operation_mode):
        """Set new target operation mode."""
        await self.hass.async_add_executor_job(self.set_operation_mode, operation_mode)

    def turn_away_mode_on(self):
        """Turn away mode on."""
        if self.device.device_data[self.device_id]['has_hot_water_control']:
            self.device.hotwater_set_away_mode(self.device_id, away_mode=True)

    async def async_turn_away_mode_on(self):
        """Turn away mode on."""
        await self.hass.async_add_executor_job(self.turn_away_mode_on)

    def turn_away_mode_off(self):
        """Turn away mode off."""
        if self.device.device_data[self.device_id]['has_hot_water_control']:
            self.device.hotwater_set_away_mode(self.device_id, away_mode=False)

    async def async_turn_away_mode_off(self):
        """Turn away mode off."""
        await self.hass.async_add_executor_job(self.turn_away_mode_off)

    def turn_boost_mode_on(self, timeToEnd):
        """Turn boost mode on."""
        if self.device.device_data[self.device_id]['has_hot_water_control']:
            self.device.hotwater_set_boost(self.device_id, time=timeToEnd)

    def turn_boost_mode_off(self):
        """Turn boost mode off."""
        if self.device.device_data[self.device_id]['has_hot_water_control']:
            self.device.hotwater_set_boost(self.device_id, time=0)

    def update(self):
        """Get the latest data from the Hot Water Sensor and updates the states."""
        self.device.update()


async def async_service_away_mode(entity, service):
    """Handle away mode service."""
    if service.data[ATTR_AWAY_MODE]:
        await entity.async_turn_away_mode_on()
    else:
        await entity.async_turn_away_mode_off()
1 Like

@mikc , thanks for working on this! I tested this although removed ``` at the end which breaks it? For me it only seems to report “Scheduled” as the status no matter what the actual status is?

I’ve managed to get the Water Heater status reporting On and Off at least by editing the code from @Zerovici to add back in a few sections from the original code. This seems to correctly allow boosting, cancelling boosting and also report when the water is on from being scheduled or from being boosted and when it is off. I can’t recall what the original status was but this is sufficient for what I need.

water_heater.py

import logging
import time
import voluptuous as vol

from datetime import datetime
from homeassistant.util.dt import now
from homeassistant.helpers import config_validation as cv
from homeassistant.const import (
    ATTR_ENTITY_ID,
    UnitOfTemperature
)
from homeassistant.components.water_heater import (
    STATE_OFF,
    STATE_ON,
    WaterHeaterEntity,
    WaterHeaterEntityFeature,
    ATTR_OPERATION_MODE,
    ATTR_AWAY_MODE
)

from .const import (
    DOMAIN,
)

STATE_SCHEDULE = 'schedule'
SERVICE_BOOST_HOT_WATER = 'boost_hot_water'
SUPPORT_BOOST_MODE = 8
ATTR_TIME_PERIOD = 'time_period'
ATTR_BOOST_MODE_STATUS = 'boost_mode_status'
ATTR_BOOST_MODE = 'boost_mode'
ATTR_HEATING_ACTIVE = 'heating_active'
ATTR_AWAY_MODE_ACTIVE = 'away_mode_active'


SUPPORTED_FEATURES = (
    WaterHeaterEntityFeature.OPERATION_MODE |
    WaterHeaterEntityFeature.AWAY_MODE |
    WaterHeaterEntityFeature.ON_OFF
)

NEST_TO_HASS_MODE = {"schedule": STATE_SCHEDULE, "off": STATE_OFF}
HASS_TO_NEST_MODE = {STATE_SCHEDULE: "schedule", STATE_OFF: "off"}
NEST_TO_HASS_STATE = {True: STATE_ON, False: STATE_OFF}
HASS_TO_NEST_STATE = {STATE_ON: True, STATE_OFF: False}
SUPPORTED_OPERATIONS = [STATE_SCHEDULE, STATE_OFF]

BOOST_HOT_WATER_SCHEMA = vol.Schema(
    {
        vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids,
        vol.Optional(ATTR_TIME_PERIOD, default=30): cv.positive_int,
        vol.Required(ATTR_BOOST_MODE): cv.boolean,
    }
)

_LOGGER = logging.getLogger(__name__)


async def async_setup_platform(hass,
                               config,
                               async_add_entities,
                               discovery_info=None):
    """Set up the Nest water heater device."""
    api = hass.data[DOMAIN]['api']

    waterheaters = []
    _LOGGER.info("Adding waterheaters")
    for waterheater in api['hotwatercontrollers']:
        _LOGGER.info(f"Adding nest waterheater uuid: {waterheater}")
        waterheaters.append(NestWaterHeater(waterheater, api))
    async_add_entities(waterheaters)

    def hot_water_boost(service):
        """Handle the service call."""
        entity_ids = service.data[ATTR_ENTITY_ID]
        minutes = service.data[ATTR_TIME_PERIOD]
        timeToEnd = int(time.mktime(datetime.timetuple(now()))+(minutes*60))
        mode = service.data[ATTR_BOOST_MODE]
        _LOGGER.debug('HW boost mode: {} ending: {}'.format(mode, timeToEnd))

        _waterheaters = [
            x for x in waterheaters if not entity_ids or x.entity_id in entity_ids
        ]

        for nest_water_heater in _waterheaters:
            if mode:
                nest_water_heater.turn_boost_mode_on(timeToEnd)
            else:
                nest_water_heater.turn_boost_mode_off()

    hass.services.async_register(
        DOMAIN,
        SERVICE_BOOST_HOT_WATER,
        hot_water_boost,
        schema=BOOST_HOT_WATER_SCHEMA,
    )


class NestWaterHeater(WaterHeaterEntity):
    """Representation of a Nest water heater device."""

    def __init__(self, device_id, api):
        """Initialize the sensor."""
        self._name = "Nest Hot Water Heater"
        self.device_id = device_id
        self.device = api
        self._attr_supported_features = SUPPORTED_FEATURES
        self._attr_operation_list = SUPPORTED_OPERATIONS
        self._attr_temperature_unit = UnitOfTemperature.CELSIUS

    @property
    def unique_id(self):
        """Return an unique ID."""
        return self.device_id + '_hw'

    @property
    def device_info(self):
        """Return device information."""
        return {"identifiers": {(DOMAIN, self.unique_id)}, "name": self.name}

    @property
    def name(self):
        """Return the name of the water heater."""
        return "{0} Hot Water".format(
            self.device.device_data[self.device_id]['name'])

    @property
    def icon(self):
        """Return the icon to use in the frontend."""
        return "mdi:water" if self.current_operation == STATE_SCHEDULE else "mdi:water-off"

    @property
    def state(self):
        """Return the (master) state of the water heater."""
        state = STATE_OFF
        if self.device.device_data[self.device_id]['hot_water_status']:
            state = NEST_TO_HASS_STATE[self.device.device_data[self.device_id]['hot_water_status']]
        return state

    @property
    def state_attributes(self):
        """Return the optional state attributes."""
        data = {}

        supported_features = self.supported_features

        # Operational mode will be off or schedule
        if WaterHeaterEntityFeature.OPERATION_MODE in supported_features:
            data[ATTR_OPERATION_MODE] = self.current_operation

        # This is, is the away mode feature turned on/off
        if WaterHeaterEntityFeature.AWAY_MODE in supported_features:
            is_away = self.is_away_mode_on
            data[ATTR_AWAY_MODE] = STATE_ON if is_away else STATE_OFF

        # away_mode_active - true if away mode is active.
        # If away mode is on, and no one has been seen for 48hrs away mode
        # should go active
        if WaterHeaterEntityFeature.AWAY_MODE in supported_features:
            if self.device.device_data[self.device_id]['hot_water_away_active']:
                away_active = self.device.device_data[self.device_id]['hot_water_away_active']
                data[ATTR_AWAY_MODE_ACTIVE] = away_active
            else:
                data[ATTR_AWAY_MODE_ACTIVE] = False

        if WaterHeaterEntityFeature.ON_OFF in supported_features: # using ON_OFF as a proxy for boost mode
            # boost_mode - true if boost mode is currently active.
            # boost_mode will be 0 if off, and non-zero otherwise - it is set to
            # the epoch time when the boost is due to end
            if self.device.device_data[self.device_id]['hot_water_boost_setting']:
                boost_mode = self.device.device_data[self.device_id]['hot_water_boost_setting']
                data[ATTR_BOOST_MODE_STATUS] = bool(boost_mode)
            else:
                data[ATTR_BOOST_MODE_STATUS] = False

        # heating_active - true if hot water is currently being heated.
        # So it is either on via schedule or boost and currently firing
        # (demand on) the boiler
        if self.device.device_data[self.device_id]['hot_water_status']:
            if self.device.device_data[self.device_id]['hot_water_actively_heating']:
                boiler_firing = self.device.device_data[self.device_id]['hot_water_actively_heating']
                data[ATTR_HEATING_ACTIVE] = boiler_firing
            else:
                data[ATTR_HEATING_ACTIVE] = False
        else:
            data[ATTR_HEATING_ACTIVE] = False

        _LOGGER.debug("Device state attributes: {}".format(data))
        return data

    @property
    def current_operation(self):
        """Return current operation ie. eco, electric, performance, ..."""
        return NEST_TO_HASS_MODE[self.device.device_data[self.device_id]['hot_water_timer_mode']]

    @property
    def is_away_mode_on(self):
        """Return true if away mode is on."""
        away = self.device.device_data[self.device_id]['hot_water_away_setting']
        return away

    def set_operation_mode(self, operation_mode):
        """Set new target operation mode."""
        if self.device.device_data[self.device_id]['has_hot_water_control']:
            self.device.hotwater_set_mode(self.device_id, mode=operation_mode)

    async def async_set_operation_mode(self, operation_mode):
        """Set new target operation mode."""
        await self.hass.async_add_executor_job(self.set_operation_mode, operation_mode)

    def turn_away_mode_on(self):
        """Turn away mode on."""
        if self.device.device_data[self.device_id]['has_hot_water_control']:
            self.device.hotwater_set_away_mode(self.device_id, away_mode=True)

    async def async_turn_away_mode_on(self):
        """Turn away mode on."""
        await self.hass.async_add_executor_job(self.turn_away_mode_on)

    def turn_away_mode_off(self):
        """Turn away mode off."""
        if self.device.device_data[self.device_id]['has_hot_water_control']:
            self.device.hotwater_set_away_mode(self.device_id, away_mode=False)

    async def async_turn_away_mode_off(self):
        """Turn away mode off."""
        await self.hass.async_add_executor_job(self.turn_away_mode_off)

    def turn_boost_mode_on(self, timeToEnd):
        """Turn boost mode on."""
        if self.device.device_data[self.device_id]['has_hot_water_control']:
            self.device.hotwater_set_boost(self.device_id, time=timeToEnd)

    def turn_boost_mode_off(self):
        """Turn boost mode off."""
        if self.device.device_data[self.device_id]['has_hot_water_control']:
            self.device.hotwater_set_boost(self.device_id, time=0)

    def update(self):
        """Get the latest data from the Hot Water Sensor and updates the states."""
        self.device.update()


async def async_service_away_mode(entity, service):
    """Handle away mode service."""
    if service.data[ATTR_AWAY_MODE]:
        await entity.async_turn_away_mode_on()
    else:
        await entity.async_turn_away_mode_off()
1 Like

Hi,

I thought I have the appropriate permissions on the badguy99 version of this integration, but it seems I was wrong. The proposed changes seem fine at first glance and I’ll try to test them locally once I’ll have a bit of time this week/weekend.

With regard to the official integration: to me it seems, that the official one does not support domestic hot water control at all :frowning:

Br,
Loci

Hi

I have similar issue too.

Until I updated to HA 2025.1 I use custom integration generic_water_heater and it worked great. but now I have error message:

Logger: homeassistant.setup
Source: setup.py:497
First occurred: 13:05:55 (1 occurrences)
Last logged: 13:05:55

Unable to prepare setup for platform 'generic_water_heater.water_heater': Platform not found (cannot import name 'SUPPORT_OPERATION_MODE' from 'homeassistant.components.water_heater' (/usr/src/homeassistant/homeassistant/components/water_heater/__init__.py)).

here is code that worked before update water_heater.py:

"""Support for generic water heater units."""
import logging

from homeassistant.components.water_heater import (
    SUPPORT_OPERATION_MODE,
    SUPPORT_TARGET_TEMPERATURE,
    DEFAULT_MIN_TEMP,
    DEFAULT_MAX_TEMP,
    WaterHeaterEntity,
)
from homeassistant.const import (
    ATTR_ENTITY_ID,
    ATTR_TEMPERATURE,
    CONF_NAME,
    SERVICE_TURN_OFF,
    SERVICE_TURN_ON,
    STATE_OFF,
    STATE_ON,
    STATE_UNAVAILABLE,
    STATE_UNKNOWN,
    TEMP_FAHRENHEIT,
)
from homeassistant.core import DOMAIN as HA_DOMAIN, callback
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.restore_state import RestoreEntity

try:
    from homeassistant.util.unit_conversion import TemperatureConverter as convert
except ImportError or ModuleNotFoundError:
    from homeassistant.util.temperature import convert as convert

from . import CONF_HEATER, CONF_SENSOR, CONF_TARGET_TEMP, CONF_TEMP_DELTA, CONF_TEMP_MIN, CONF_TEMP_MAX

_LOGGER = logging.getLogger(__name__)

SUPPORT_FLAGS_HEATER = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE
DEFAULT_NAME = "Generic Water Heater"


async def async_setup_platform(
    hass, hass_config, async_add_entities, discovery_info=None
):
    """Set up the generic water_heater devices."""
    entities = []

    for config in discovery_info:
        name = config[CONF_NAME]
        heater_entity_id = config[CONF_HEATER]
        sensor_entity_id = config[CONF_SENSOR]
        target_temp = config.get(CONF_TARGET_TEMP)
        temp_delta = config.get(CONF_TEMP_DELTA)
        min_temp = config.get(CONF_TEMP_MIN)
        max_temp = config.get(CONF_TEMP_MAX)
        unit = hass.config.units.temperature_unit

        entities.append(
            GenericWaterHeater(
                name, heater_entity_id, sensor_entity_id, target_temp, temp_delta, min_temp, max_temp, unit
            )
        )

    async_add_entities(entities)


class GenericWaterHeater(WaterHeaterEntity, RestoreEntity):
    """Representation of a generic water_heater device."""

    def __init__(
        self, name, heater_entity_id, sensor_entity_id, target_temp, temp_delta, min_temp, max_temp, unit
    ):
        """Initialize the water_heater device."""
        self._attr_name = name
        self.heater_entity_id = heater_entity_id
        self.sensor_entity_id = sensor_entity_id
        self._support_flags = SUPPORT_FLAGS_HEATER
        self._target_temperature = target_temp
        self._temperature_delta = temp_delta
        self._min_temp = min_temp
        self._max_temp = max_temp
        self._unit_of_measurement = unit
        self._current_operation = STATE_ON
        self._current_temperature = None
        self._operation_list = [
            STATE_ON,
            STATE_OFF,
        ]
        self._attr_available = False
        self._attr_should_poll = False

    @property
    def supported_features(self):
        """Return the list of supported features."""
        return self._support_flags

    @property
    def current_temperature(self):
        """Return current temperature."""
        return self._current_temperature

    @property
    def temperature_unit(self):
        """Return the unit of measurement."""
        return self._unit_of_measurement

    @property
    def target_temperature(self):
        """Return the temperature we try to reach."""
        return self._target_temperature

    @property
    def current_operation(self):
        """Return current operation ie. on, off."""
        return self._current_operation

    @property
    def operation_list(self):
        """Return the list of available operation modes."""
        return self._operation_list

    @property
    def min_temp(self):
        """Return the minimum targetable temperature."""
        """If the min temperature is not set on the config, returns the HA default for Water Heaters."""
        if not self._min_temp:
            self._min_temp = convert(DEFAULT_MIN_TEMP, TEMP_FAHRENHEIT, self._unit_of_measurement)
        return self._min_temp

    @property
    def max_temp(self):
        """Return the maximum targetable temperature."""
        """If the max temperature is not set on the config, returns the HA default for Water Heaters."""
        if not self._max_temp:
            self._max_temp = convert(DEFAULT_MAX_TEMP, TEMP_FAHRENHEIT, self._unit_of_measurement)
        return self._max_temp

    async def async_set_temperature(self, **kwargs):
        """Set new target temperatures."""
        self._target_temperature = kwargs.get(ATTR_TEMPERATURE)
        await self._async_control_heating()

    async def async_set_operation_mode(self, operation_mode):
        """Set new operation mode."""
        self._current_operation = operation_mode
        await self._async_control_heating()

    async def async_added_to_hass(self):
        """Run when entity about to be added."""
        await super().async_added_to_hass()

        self.async_on_remove(
            async_track_state_change_event(
                self.hass, [self.sensor_entity_id], self._async_sensor_changed
            )
        )
        self.async_on_remove(
            async_track_state_change_event(
                self.hass, [self.heater_entity_id], self._async_switch_changed
            )
        )

        old_state = await self.async_get_last_state()
        if old_state is not None:
            if old_state.attributes.get(ATTR_TEMPERATURE) is not None:
                self._target_temperature = float(old_state.attributes.get(ATTR_TEMPERATURE))
            self._current_operation = old_state.state

        temp_sensor = self.hass.states.get(self.sensor_entity_id)
        if temp_sensor and temp_sensor.state not in (
            STATE_UNAVAILABLE,
            STATE_UNKNOWN,
        ):
            self._current_temperature = float(temp_sensor.state)

        heater_switch = self.hass.states.get(self.heater_entity_id)
        if heater_switch and heater_switch.state not in (
            STATE_UNAVAILABLE,
            STATE_UNKNOWN,
        ):
            self._attr_available = True
        self.async_write_ha_state()

    async def _async_sensor_changed(self, event):
        """Handle temperature changes."""
        new_state = event.data.get("new_state")
        if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
            # Failsafe
            _LOGGER.warning(
                "No Temperature information, entering Failsafe, turning off heater %s",
                self.heater_entity_id,
            )
            await self._async_heater_turn_off()
            self._current_temperature = None
        else:
            self._current_temperature = float(new_state.state)

        await self._async_control_heating()

    @callback
    def _async_switch_changed(self, event):
        """Handle heater switch state changes."""
        new_state = event.data.get("new_state")
        _LOGGER.debug(f"New switch state = {new_state}")
        if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
            self._attr_available = False
        else:
            self._attr_available = True
            _LOGGER.debug("%s became Available", self.name)
            if new_state.state == STATE_ON and self._current_operation == STATE_OFF:
                self._current_operation = STATE_ON
                _LOGGER.debug("STATE_ON")
            elif new_state.state == STATE_OFF and self._current_operation == STATE_ON:
                self._current_operation = STATE_OFF
                _LOGGER.debug("STATE_OFF")

        self.async_write_ha_state()

    async def _async_control_heating(self):
        """Check if we need to turn heating on or off."""
        if self._current_operation == STATE_OFF or self._current_temperature is None:
            pass
        elif (
            abs(self._current_temperature - self._target_temperature) > self._temperature_delta
        ):
            if self._current_temperature < self._target_temperature:
                await self._async_heater_turn_on()
            else:
                await self._async_heater_turn_off()
        self.async_write_ha_state()

    async def _async_heater_turn_on(self):
        """Turn heater toggleable device on."""
        heater = self.hass.states.get(self.heater_entity_id)
        if heater is None or heater.state == STATE_ON:
            return

        _LOGGER.debug("Turning on heater %s", self.heater_entity_id)
        data = {ATTR_ENTITY_ID: self.heater_entity_id}
        await self.hass.services.async_call(
            HA_DOMAIN, SERVICE_TURN_ON, data, context=self._context
        )

    async def _async_heater_turn_off(self):
        """Turn heater toggleable device off."""
        heater = self.hass.states.get(self.heater_entity_id)
        if heater is None or heater.state == STATE_OFF:
            return

        _LOGGER.debug("Turning off heater %s", self.heater_entity_id)
        data = {ATTR_ENTITY_ID: self.heater_entity_id}
        await self.hass.services.async_call(
            HA_DOMAIN, SERVICE_TURN_OFF, data, context=self._context
        )

Can someone help me?

Today author of integration repaired it.