From 3e9fe3e06f14904ea0517bbb924853ea87ec2a86 Mon Sep 17 00:00:00 2001 From: Guy Badman <61918526+badguy99@users.noreply.github.com> Date: Wed, 22 Apr 2020 17:56:16 +0100 Subject: [PATCH] Initial commit of water_heater support for badnest This commit allows the UK 3rd Generation Learning Thermostat, which has support for controlling hot water heating. Support is added for: - Setting mode to Off or Schedule - Setting Home/Away Assist Off or On - Setting Boost mode On or Off (with thanks to the work done by @c-garner which this is based on) - Status of hot water heating - is heating active True/False (Matches when the Nest app goes orange) - Status of boost mode - True if boost is active - Status of Away Mode Active - True if no one's been home for 48hrs and Home/Away Assist is On, which turns off hot water schedule --- custom_components/badnest/api.py | 104 +++++++++ custom_components/badnest/services.yaml | 12 + custom_components/badnest/water_heater.py | 267 ++++++++++++++++++++++ hacs.json | 2 +- info.md | 7 + 5 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 custom_components/badnest/services.yaml create mode 100644 custom_components/badnest/water_heater.py diff --git a/custom_components/badnest/api.py b/custom_components/badnest/api.py index 6695414..fc3780e 100644 --- a/custom_components/badnest/api.py +++ b/custom_components/badnest/api.py @@ -48,6 +48,7 @@ class NestAPI(): self.cameras = [] self.thermostats = [] self.temperature_sensors = [] + self.hotwatercontrollers = [] self.protects = [] self.login() self._get_devices() @@ -153,6 +154,7 @@ class NestAPI(): sn = bucket.replace('device.', '') self.thermostats.append(sn) self.temperature_sensors.append(sn) + self.hotwatercontrollers.append(sn) self.device_data[sn] = {} self.cameras = self._get_cameras() @@ -270,6 +272,25 @@ class NestAPI(): self.device_data[sn]['eco'] = True else: self.device_data[sn]['eco'] = False + + # Hot water + # - Status + self.device_data[sn]['has_hot_water_control'] = \ + sensor_data["has_hot_water_control"] + self.device_data[sn]['hot_water_status'] = \ + sensor_data["hot_water_active"] + self.device_data[sn]['hot_water_actively_heating'] = \ + sensor_data["hot_water_boiling_state"] + self.device_data[sn]['hot_water_away_active'] = \ + sensor_data["hot_water_away_active"] + # - Status/Settings + self.device_data[sn]['hot_water_timer_mode'] = \ + sensor_data["hot_water_mode"] + self.device_data[sn]['hot_water_away_setting'] = \ + sensor_data["hot_water_away_enabled"] + self.device_data[sn]['hot_water_boost_setting'] = \ + sensor_data["hot_water_boost_time_to_end"] + # Protect elif bucket["object_key"].startswith( f"topaz.{sn}"): @@ -484,6 +505,89 @@ class NestAPI(): self.login() self.thermostat_set_eco_mode(device_id, state) + def hotwater_set_boost(self, device_id, time): + if device_id not in self.hotwatercontrollers: + return + + try: + self._session.post( + f"{self._czfe_url}/v5/put", + json={ + "objects": [ + { + "object_key": f'device.{device_id}', + "op": "MERGE", + "value": {"hot_water_boost_time_to_end": time}, + } + ] + }, + headers={"Authorization": f"Basic {self._access_token}"}, + ) + except requests.exceptions.RequestException as e: + _LOGGER.error(e) + _LOGGER.error('Failed to boost hot water, trying again') + self.hotwater_set_boost(device_id, time) + except KeyError: + _LOGGER.debug('Failed to boost hot water, trying to log in again') + self.login() + self.hotwater_set_boost(device_id, time) + + def hotwater_set_away_mode(self, device_id, away_mode): + if device_id not in self.hotwatercontrollers: + return + + try: + self._session.post( + f"{self._czfe_url}/v5/put", + json={ + "objects": [ + { + "object_key": f'device.{device_id}', + "op": "MERGE", + "value": {"hot_water_away_enabled": away_mode}, + } + ] + }, + headers={"Authorization": f"Basic {self._access_token}"}, + ) + except requests.exceptions.RequestException as e: + _LOGGER.error(e) + _LOGGER.error('Failed to set hot water away mode, trying again') + self.hotwater_set_away_mode(device_id, away_mode) + except KeyError: + _LOGGER.debug('Failed to set hot water away mode, ' + 'trying to log in again') + self.login() + self.hotwater_set_away_mode(device_id, away_mode) + + def hotwater_set_mode(self, device_id, mode): + if device_id not in self.hotwatercontrollers: + return + + try: + self._session.post( + f"{self._czfe_url}/v5/put", + json={ + "objects": [ + { + "object_key": f'device.{device_id}', + "op": "MERGE", + "value": {"hot_water_mode": mode}, + } + ] + }, + headers={"Authorization": f"Basic {self._access_token}"}, + ) + except requests.exceptions.RequestException as e: + _LOGGER.error(e) + _LOGGER.error('Failed to set hot water mode, trying again') + self.hotwater_set_boost(device_id, mode) + except KeyError: + _LOGGER.debug('Failed to set hot water mode, ' + 'trying to log in again') + self.login() + self.hotwater_set_boost(device_id, mode) + def _camera_set_properties(self, device_id, property, value): if device_id not in self.cameras: return diff --git a/custom_components/badnest/services.yaml b/custom_components/badnest/services.yaml new file mode 100644 index 0000000..88a109d --- /dev/null +++ b/custom_components/badnest/services.yaml @@ -0,0 +1,12 @@ +boost_hot_water: + description: Turn boost mode on for the period of time specified, or off." + fields: + entity_id: + description: Name(s) of entites to change. + example: "water_heater.hot_water" + time_period: + description: Time period in minutes for the boost. + example: 30 + boost_mode: + description: Enable boost funcion. + example: True diff --git a/custom_components/badnest/water_heater.py b/custom_components/badnest/water_heater.py new file mode 100644 index 0000000..1247cfe --- /dev/null +++ b/custom_components/badnest/water_heater.py @@ -0,0 +1,267 @@ +import logging +import time +import voluptuous as vol + +from datetime import datetime +from homeassistant.util.dt import utcnow +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers import config_validation as cv +from homeassistant.const import ( + ATTR_ENTITY_ID, +) +from homeassistant.components.water_heater import ( + WaterHeaterDevice, + SCAN_INTERVAL, + STATE_OFF, + STATE_ON, + STATE_ECO, + SUPPORT_OPERATION_MODE, + SUPPORT_AWAY_MODE, + ATTR_AWAY_MODE, + ATTR_OPERATION_MODE, + ATTR_OPERATION_LIST, +) +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 = SUPPORT_OPERATION_MODE | SUPPORT_AWAY_MODE | SUPPORT_BOOST_MODE +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.""" + all_waterheaters = api['hotwatercontrollers'] + + entity_ids = service.data[ATTR_ENTITY_ID] + minutes = service.data[ATTR_TIME_PERIOD] + timeToEnd = int(time.mktime(datetime.timetuple(utcnow()))+(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: + _LOGGER.info('HW boost mode on time ending: {}'.format(timeToEnd)) + nest_water_heater.turn_boost_mode_on(timeToEnd) + else: + _LOGGER.info('HW boost off') + 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(WaterHeaterDevice): + """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 + + @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 supported_features(self): + """Return the list of supported features.""" + return SUPPORTED_FEATURES + + @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 capability_attributes(self): + """Return capability attributes.""" + supported_features = self.supported_features or 0 + + data = {} + + if supported_features & SUPPORT_OPERATION_MODE: + data[ATTR_OPERATION_LIST] = self.operation_list + + return data + + @property + def state_attributes(self): + """Return the optional state attributes.""" + data = {} + + supported_features = self.supported_features + + # Operational mode will be off or schedule + if supported_features & SUPPORT_OPERATION_MODE: + data[ATTR_OPERATION_MODE] = self.current_operation + + # This is, is the away mode feature turned on/off + if supported_features & SUPPORT_AWAY_MODE: + 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 supported_features & SUPPORT_AWAY_MODE: + 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 supported_features & SUPPORT_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_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 + + _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 operation_list(self): + """Return the list of available operation modes.""" + return SUPPORTED_OPERATIONS + + @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() diff --git a/hacs.json b/hacs.json index 57cbcb3..db677c6 100644 --- a/hacs.json +++ b/hacs.json @@ -1,4 +1,4 @@ { "name": "Bad Nest", - "domains": ["climate", "camera", "sensor"] + "domains": ["climate", "camera", "sensor", "water_heater"] } diff --git a/info.md b/info.md index 7e14cff..4c7c894 100644 --- a/info.md +++ b/info.md @@ -13,6 +13,7 @@ This isn't an advertised or public API, it's still better than web scraping, but - Nest Protect support - Nest Thermostat support - Nest Thermostat Sensor support +- Nest Thermostat Hot Water (water heater) support (UK) - Nest Camera support ## Drawbacks @@ -52,6 +53,9 @@ camera: sensor: - platform: badnest + +water_heater: + - platform: badnest ``` ### Example configuration.yaml - When you are using the Google Auth Login @@ -71,6 +75,9 @@ camera: sensor: - platform: badnest + +water_heater: + - platform: badnest ``` Google Login support added with many thanks to: chrisjshull from