From f2d8f3bcb86a02870de966a940117337005fc6ed Mon Sep 17 00:00:00 2001 From: William Scanlon Date: Mon, 8 Oct 2018 04:38:07 -0400 Subject: [PATCH] Water heater support (#17058) * Moved econet to water_heater * Wink and Econet are now in water heater! * Removed away mode from econet and added demo water heater * Added demo tests * Updated coveragerc * Fix lint issues. * updated requirements all * Requirements all actually updated. * Reset wink and econet and fixed service. * Reset wink and econet to the correct dev state * Reset requirements_all and .coveragerc and removed the new econet and wink water_heater files * Removed @bind_hass service methods * Actually reset the .coverage file * Fixed the tests * Addressed @MartinHjelmare's comments * Removed unused import * Switched to async_add_executor_job * Fixed lint * Removed is_on * Added celsius demo water heater and tests. * Removed metric import --- .../components/water_heater/__init__.py | 263 ++++++++++++++++++ homeassistant/components/water_heater/demo.py | 110 ++++++++ .../components/water_heater/services.yaml | 29 ++ tests/components/water_heater/__init__.py | 1 + tests/components/water_heater/common.py | 51 ++++ tests/components/water_heater/test_demo.py | 118 ++++++++ 6 files changed, 572 insertions(+) create mode 100644 homeassistant/components/water_heater/__init__.py create mode 100644 homeassistant/components/water_heater/demo.py create mode 100644 homeassistant/components/water_heater/services.yaml create mode 100644 tests/components/water_heater/__init__.py create mode 100644 tests/components/water_heater/common.py create mode 100644 tests/components/water_heater/test_demo.py diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py new file mode 100644 index 00000000000..92dbebc4421 --- /dev/null +++ b/homeassistant/components/water_heater/__init__.py @@ -0,0 +1,263 @@ +""" +Provides functionality to interact with water heater devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/water_heater/ +""" +from datetime import timedelta +import logging +import functools as ft + +import voluptuous as vol + +from homeassistant.helpers.temperature import display_temp as show_temp +from homeassistant.util.temperature import convert as convert_temperature +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_TEMPERATURE, SERVICE_TURN_ON, SERVICE_TURN_OFF, + STATE_ON, STATE_OFF, TEMP_CELSIUS, PRECISION_WHOLE, + PRECISION_TENTHS, TEMP_FAHRENHEIT) + +DEFAULT_MIN_TEMP = 110 +DEFAULT_MAX_TEMP = 140 + +DOMAIN = 'water_heater' + +ENTITY_ID_FORMAT = DOMAIN + '.{}' +SCAN_INTERVAL = timedelta(seconds=60) + +SERVICE_SET_AWAY_MODE = 'set_away_mode' +SERVICE_SET_TEMPERATURE = 'set_temperature' +SERVICE_SET_OPERATION_MODE = 'set_operation_mode' + +STATE_ECO = 'eco' +STATE_ELECTRIC = 'electric' +STATE_PERFORMANCE = 'performance' +STATE_HIGH_DEMAND = 'high_demand' +STATE_HEAT_PUMP = 'heat_pump' +STATE_GAS = 'gas' + +SUPPORT_TARGET_TEMPERATURE = 1 +SUPPORT_OPERATION_MODE = 2 +SUPPORT_AWAY_MODE = 4 + +ATTR_MAX_TEMP = 'max_temp' +ATTR_MIN_TEMP = 'min_temp' +ATTR_AWAY_MODE = 'away_mode' +ATTR_OPERATION_MODE = 'operation_mode' +ATTR_OPERATION_LIST = 'operation_list' + +CONVERTIBLE_ATTRIBUTE = [ + ATTR_TEMPERATURE, +] + +_LOGGER = logging.getLogger(__name__) + +ON_OFF_SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + +SET_AWAY_MODE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_AWAY_MODE): cv.boolean, +}) +SET_TEMPERATURE_SCHEMA = vol.Schema(vol.All( + { + vol.Required(ATTR_TEMPERATURE, 'temperature'): vol.Coerce(float), + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_OPERATION_MODE): cv.string, + } +)) +SET_OPERATION_MODE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_OPERATION_MODE): cv.string, +}) + + +async def async_setup(hass, config): + """Set up water_heater devices.""" + component = hass.data[DOMAIN] = \ + EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) + await component.async_setup(config) + + component.async_register_entity_service( + SERVICE_SET_AWAY_MODE, SET_AWAY_MODE_SCHEMA, + async_service_away_mode + ) + component.async_register_entity_service( + SERVICE_SET_TEMPERATURE, SET_TEMPERATURE_SCHEMA, + async_service_temperature_set + ) + component.async_register_entity_service( + SERVICE_SET_OPERATION_MODE, SET_OPERATION_MODE_SCHEMA, + 'async_set_operation_mode' + ) + component.async_register_entity_service( + SERVICE_TURN_OFF, ON_OFF_SERVICE_SCHEMA, + 'async_turn_off' + ) + component.async_register_entity_service( + SERVICE_TURN_ON, ON_OFF_SERVICE_SCHEMA, + 'async_turn_on' + ) + + return True + + +async def async_setup_entry(hass, entry): + """Set up a config entry.""" + return await hass.data[DOMAIN].async_setup_entry(entry) + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) + + +class WaterHeaterDevice(Entity): + """Representation of a water_heater device.""" + + @property + def state(self): + """Return the current state.""" + return self.current_operation + + @property + def precision(self): + """Return the precision of the system.""" + if self.hass.config.units.temperature_unit == TEMP_CELSIUS: + return PRECISION_TENTHS + return PRECISION_WHOLE + + @property + def state_attributes(self): + """Return the optional state attributes.""" + data = { + ATTR_MIN_TEMP: show_temp( + self.hass, self.min_temp, self.temperature_unit, + self.precision), + ATTR_MAX_TEMP: show_temp( + self.hass, self.max_temp, self.temperature_unit, + self.precision), + ATTR_TEMPERATURE: show_temp( + self.hass, self.target_temperature, self.temperature_unit, + self.precision), + } + + supported_features = self.supported_features + + if supported_features & SUPPORT_OPERATION_MODE: + data[ATTR_OPERATION_MODE] = self.current_operation + if self.operation_list: + data[ATTR_OPERATION_LIST] = self.operation_list + + 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 + + return data + + @property + def temperature_unit(self): + """Return the unit of measurement used by the platform.""" + raise NotImplementedError + + @property + def current_operation(self): + """Return current operation ie. eco, electric, performance, ...""" + return None + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return None + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return None + + @property + def is_away_mode_on(self): + """Return true if away mode is on.""" + return None + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + raise NotImplementedError() + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + await self.hass.async_add_executor_job( + ft.partial(self.set_temperature, **kwargs)) + + def set_operation_mode(self, operation_mode): + """Set new target operation mode.""" + raise NotImplementedError() + + 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.""" + raise NotImplementedError() + + 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.""" + raise NotImplementedError() + + async def async_turn_away_mode_off(self): + """Turn away mode off.""" + await self.hass.async_add_executor_job(self.turn_away_mode_off) + + @property + def supported_features(self): + """Return the list of supported features.""" + raise NotImplementedError() + + @property + def min_temp(self): + """Return the minimum temperature.""" + return convert_temperature(DEFAULT_MIN_TEMP, TEMP_FAHRENHEIT, + self.temperature_unit) + + @property + def max_temp(self): + """Return the maximum temperature.""" + return convert_temperature(DEFAULT_MAX_TEMP, TEMP_FAHRENHEIT, + self.temperature_unit) + + +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() + + +async def async_service_temperature_set(entity, service): + """Handle set temperature service.""" + hass = entity.hass + kwargs = {} + + for value, temp in service.data.items(): + if value in CONVERTIBLE_ATTRIBUTE: + kwargs[value] = convert_temperature( + temp, + hass.config.units.temperature_unit, + entity.temperature_unit + ) + else: + kwargs[value] = temp + + await entity.async_set_temperature(**kwargs) diff --git a/homeassistant/components/water_heater/demo.py b/homeassistant/components/water_heater/demo.py new file mode 100644 index 00000000000..89b86c12af4 --- /dev/null +++ b/homeassistant/components/water_heater/demo.py @@ -0,0 +1,110 @@ +""" +Demo platform that offers a fake water_heater device. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/demo/ +""" +from homeassistant.components.water_heater import ( + WaterHeaterDevice, + SUPPORT_TARGET_TEMPERATURE, + SUPPORT_AWAY_MODE, + SUPPORT_OPERATION_MODE) +from homeassistant.const import TEMP_FAHRENHEIT, ATTR_TEMPERATURE, TEMP_CELSIUS + +SUPPORT_FLAGS_HEATER = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | + SUPPORT_AWAY_MODE) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Demo water_heater devices.""" + add_entities([ + DemoWaterHeater('Demo Water Heater', 119, + TEMP_FAHRENHEIT, False, 'eco'), + DemoWaterHeater('Demo Water Heater Celsius', 45, + TEMP_CELSIUS, True, 'eco') + + ]) + + +class DemoWaterHeater(WaterHeaterDevice): + """Representation of a demo water_heater device.""" + + def __init__(self, name, target_temperature, unit_of_measurement, + away, current_operation): + """Initialize the water_heater device.""" + self._name = name + self._support_flags = SUPPORT_FLAGS_HEATER + if target_temperature is not None: + self._support_flags = \ + self._support_flags | SUPPORT_TARGET_TEMPERATURE + if away is not None: + self._support_flags = self._support_flags | SUPPORT_AWAY_MODE + if current_operation is not None: + self._support_flags = self._support_flags | SUPPORT_OPERATION_MODE + self._target_temperature = target_temperature + self._unit_of_measurement = unit_of_measurement + self._away = away + self._current_operation = current_operation + self._operation_list = ['eco', 'electric', 'performance', + 'high_demand', 'heat_pump', 'gas', + 'off'] + + @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 False + + @property + def name(self): + """Return the name of the water_heater device.""" + return self._name + + @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. heat, cool, idle.""" + return self._current_operation + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return self._operation_list + + @property + def is_away_mode_on(self): + """Return if away mode is on.""" + return self._away + + def set_temperature(self, **kwargs): + """Set new target temperatures.""" + self._target_temperature = kwargs.get(ATTR_TEMPERATURE) + self.schedule_update_ha_state() + + def set_operation_mode(self, operation_mode): + """Set new operation mode.""" + self._current_operation = operation_mode + self.schedule_update_ha_state() + + def turn_away_mode_on(self): + """Turn away mode on.""" + self._away = True + self.schedule_update_ha_state() + + def turn_away_mode_off(self): + """Turn away mode off.""" + self._away = False + self.schedule_update_ha_state() diff --git a/homeassistant/components/water_heater/services.yaml b/homeassistant/components/water_heater/services.yaml new file mode 100644 index 00000000000..959d1ca9790 --- /dev/null +++ b/homeassistant/components/water_heater/services.yaml @@ -0,0 +1,29 @@ +# Describes the format for available water_heater services + +set_away_mode: + description: Turn away mode on/off for water_heater device. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'water_heater.water_heater' + away_mode: + description: New value of away mode. + example: true +set_temperature: + description: Set target temperature of water_heater device. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'water_heater.water_heater' + temperature: + description: New target temperature for water heater. + example: 25 +set_operation_mode: + description: Set operation mode for water_heater device. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'water_heater.water_heater' + operation_mode: + description: New value of operation mode. + example: eco diff --git a/tests/components/water_heater/__init__.py b/tests/components/water_heater/__init__.py new file mode 100644 index 00000000000..673119bf16e --- /dev/null +++ b/tests/components/water_heater/__init__.py @@ -0,0 +1 @@ +"""The tests for water heater component.""" diff --git a/tests/components/water_heater/common.py b/tests/components/water_heater/common.py new file mode 100644 index 00000000000..34173e7f110 --- /dev/null +++ b/tests/components/water_heater/common.py @@ -0,0 +1,51 @@ +"""Collection of helper methods. + +All containing methods are legacy helpers that should not be used by new +components. Instead call the service directly. +""" +from homeassistant.components.water_heater import ( + _LOGGER, ATTR_AWAY_MODE, + ATTR_OPERATION_MODE, DOMAIN, SERVICE_SET_AWAY_MODE, + SERVICE_SET_TEMPERATURE, SERVICE_SET_OPERATION_MODE) +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_TEMPERATURE) +from homeassistant.loader import bind_hass + + +@bind_hass +def set_away_mode(hass, away_mode, entity_id=None): + """Turn all or specified water_heater devices away mode on.""" + data = { + ATTR_AWAY_MODE: away_mode + } + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_SET_AWAY_MODE, data) + + +@bind_hass +def set_temperature(hass, temperature=None, entity_id=None, + operation_mode=None): + """Set new target temperature.""" + kwargs = { + key: value for key, value in [ + (ATTR_TEMPERATURE, temperature), + (ATTR_ENTITY_ID, entity_id), + (ATTR_OPERATION_MODE, operation_mode) + ] if value is not None + } + _LOGGER.debug("set_temperature start data=%s", kwargs) + hass.services.call(DOMAIN, SERVICE_SET_TEMPERATURE, kwargs) + + +@bind_hass +def set_operation_mode(hass, operation_mode, entity_id=None): + """Set new target operation mode.""" + data = {ATTR_OPERATION_MODE: operation_mode} + + if entity_id is not None: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_SET_OPERATION_MODE, data) diff --git a/tests/components/water_heater/test_demo.py b/tests/components/water_heater/test_demo.py new file mode 100644 index 00000000000..14fe57de99c --- /dev/null +++ b/tests/components/water_heater/test_demo.py @@ -0,0 +1,118 @@ +"""The tests for the demo water_heater component.""" +import unittest + +from homeassistant.util.unit_system import ( + IMPERIAL_SYSTEM +) +from homeassistant.setup import setup_component +from homeassistant.components import water_heater + +from tests.common import get_test_home_assistant +from tests.components.water_heater import common + + +ENTITY_WATER_HEATER = 'water_heater.demo_water_heater' +ENTITY_WATER_HEATER_CELSIUS = 'water_heater.demo_water_heater_celsius' + + +class TestDemowater_heater(unittest.TestCase): + """Test the demo water_heater.""" + + def setUp(self): # pylint: disable=invalid-name + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.units = IMPERIAL_SYSTEM + self.assertTrue(setup_component(self.hass, water_heater.DOMAIN, { + 'water_heater': { + 'platform': 'demo', + }})) + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_setup_params(self): + """Test the initial parameters.""" + state = self.hass.states.get(ENTITY_WATER_HEATER) + self.assertEqual(119, state.attributes.get('temperature')) + self.assertEqual('off', state.attributes.get('away_mode')) + self.assertEqual("eco", state.attributes.get('operation_mode')) + + def test_default_setup_params(self): + """Test the setup with default parameters.""" + state = self.hass.states.get(ENTITY_WATER_HEATER) + self.assertEqual(110, state.attributes.get('min_temp')) + self.assertEqual(140, state.attributes.get('max_temp')) + + def test_set_only_target_temp_bad_attr(self): + """Test setting the target temperature without required attribute.""" + state = self.hass.states.get(ENTITY_WATER_HEATER) + self.assertEqual(119, state.attributes.get('temperature')) + common.set_temperature(self.hass, None, ENTITY_WATER_HEATER) + self.hass.block_till_done() + self.assertEqual(119, state.attributes.get('temperature')) + + def test_set_only_target_temp(self): + """Test the setting of the target temperature.""" + state = self.hass.states.get(ENTITY_WATER_HEATER) + self.assertEqual(119, state.attributes.get('temperature')) + common.set_temperature(self.hass, 110, ENTITY_WATER_HEATER) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_WATER_HEATER) + self.assertEqual(110, state.attributes.get('temperature')) + + def test_set_operation_bad_attr_and_state(self): + """Test setting operation mode without required attribute. + + Also check the state. + """ + state = self.hass.states.get(ENTITY_WATER_HEATER) + self.assertEqual("eco", state.attributes.get('operation_mode')) + self.assertEqual("eco", state.state) + common.set_operation_mode(self.hass, None, ENTITY_WATER_HEATER) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_WATER_HEATER) + self.assertEqual("eco", state.attributes.get('operation_mode')) + self.assertEqual("eco", state.state) + + def test_set_operation(self): + """Test setting of new operation mode.""" + state = self.hass.states.get(ENTITY_WATER_HEATER) + self.assertEqual("eco", state.attributes.get('operation_mode')) + self.assertEqual("eco", state.state) + common.set_operation_mode(self.hass, "electric", ENTITY_WATER_HEATER) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_WATER_HEATER) + self.assertEqual("electric", state.attributes.get('operation_mode')) + self.assertEqual("electric", state.state) + + def test_set_away_mode_bad_attr(self): + """Test setting the away mode without required attribute.""" + state = self.hass.states.get(ENTITY_WATER_HEATER) + self.assertEqual('off', state.attributes.get('away_mode')) + common.set_away_mode(self.hass, None, ENTITY_WATER_HEATER) + self.hass.block_till_done() + self.assertEqual('off', state.attributes.get('away_mode')) + + def test_set_away_mode_on(self): + """Test setting the away mode on/true.""" + common.set_away_mode(self.hass, True, ENTITY_WATER_HEATER) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_WATER_HEATER) + self.assertEqual('on', state.attributes.get('away_mode')) + + def test_set_away_mode_off(self): + """Test setting the away mode off/false.""" + common.set_away_mode(self.hass, False, ENTITY_WATER_HEATER_CELSIUS) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_WATER_HEATER_CELSIUS) + self.assertEqual('off', state.attributes.get('away_mode')) + + def test_set_only_target_temp_with_convert(self): + """Test the setting of the target temperature.""" + state = self.hass.states.get(ENTITY_WATER_HEATER_CELSIUS) + self.assertEqual(113, state.attributes.get('temperature')) + common.set_temperature(self.hass, 114, ENTITY_WATER_HEATER_CELSIUS) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_WATER_HEATER_CELSIUS) + self.assertEqual(114, state.attributes.get('temperature'))