From ada4de3ffb0476fc5f20a85bac3958637ec33552 Mon Sep 17 00:00:00 2001 From: John Arild Berentsen <turbokongen@hotmail.com> Date: Fri, 19 Aug 2016 09:17:28 +0200 Subject: [PATCH] Migrate Thermostat and HVAC component to climate component (#2825) * First draft for climate * Updates for thermostats --- .coveragerc | 6 + homeassistant/components/climate/__init__.py | 535 ++++++++++++++++++ homeassistant/components/climate/demo.py | 164 ++++++ homeassistant/components/climate/ecobee.py | 247 ++++++++ .../components/climate/eq3btsmart.py | 90 +++ .../components/climate/generic_thermostat.py | 216 +++++++ homeassistant/components/climate/heatmiser.py | 114 ++++ homeassistant/components/climate/homematic.py | 90 +++ homeassistant/components/climate/honeywell.py | 266 +++++++++ homeassistant/components/climate/knx.py | 83 +++ homeassistant/components/climate/nest.py | 189 +++++++ homeassistant/components/climate/proliphix.py | 90 +++ .../components/climate/radiotherm.py | 136 +++++ .../components/climate/services.yaml | 84 +++ homeassistant/components/climate/zwave.py | 253 +++++++++ homeassistant/components/ecobee.py | 2 +- homeassistant/components/homematic.py | 4 +- homeassistant/components/nest.py | 2 +- homeassistant/components/thermostat/zwave.py | 4 + homeassistant/components/zwave.py | 20 +- requirements_all.txt | 6 + tests/components/climate/__init__.py | 1 + tests/components/climate/test_demo.py | 166 ++++++ .../climate/test_generic_thermostat.py | 493 ++++++++++++++++ tests/components/climate/test_honeywell.py | 377 ++++++++++++ 25 files changed, 3621 insertions(+), 17 deletions(-) create mode 100644 homeassistant/components/climate/__init__.py create mode 100644 homeassistant/components/climate/demo.py create mode 100644 homeassistant/components/climate/ecobee.py create mode 100644 homeassistant/components/climate/eq3btsmart.py create mode 100644 homeassistant/components/climate/generic_thermostat.py create mode 100644 homeassistant/components/climate/heatmiser.py create mode 100644 homeassistant/components/climate/homematic.py create mode 100644 homeassistant/components/climate/honeywell.py create mode 100644 homeassistant/components/climate/knx.py create mode 100644 homeassistant/components/climate/nest.py create mode 100644 homeassistant/components/climate/proliphix.py create mode 100644 homeassistant/components/climate/radiotherm.py create mode 100644 homeassistant/components/climate/services.yaml create mode 100755 homeassistant/components/climate/zwave.py create mode 100644 tests/components/climate/__init__.py create mode 100644 tests/components/climate/test_demo.py create mode 100644 tests/components/climate/test_generic_thermostat.py create mode 100644 tests/components/climate/test_honeywell.py diff --git a/.coveragerc b/.coveragerc index d686a035687..f68872401c2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -109,6 +109,12 @@ omit = homeassistant/components/camera/generic.py homeassistant/components/camera/mjpeg.py homeassistant/components/camera/rpi_camera.py + homeassistant/components/climate/eq3btsmart.py + homeassistant/components/climate/heatmiser.py + homeassistant/components/climate/homematic.py + homeassistant/components/climate/knx.py + homeassistant/components/climate/proliphix.py + homeassistant/components/climate/radiotherm.py homeassistant/components/device_tracker/actiontec.py homeassistant/components/device_tracker/aruba.py homeassistant/components/device_tracker/asuswrt.py diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py new file mode 100644 index 00000000000..6ed289b2008 --- /dev/null +++ b/homeassistant/components/climate/__init__.py @@ -0,0 +1,535 @@ +""" +Provides functionality to interact with climate devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/climate/ +""" +import logging +import os +from numbers import Number +import voluptuous as vol + +from homeassistant.helpers.entity_component import EntityComponent + +from homeassistant.config import load_yaml_config_file +import homeassistant.util as util +from homeassistant.util.temperature import convert as convert_temperature +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, STATE_ON, STATE_OFF, STATE_UNKNOWN, + TEMP_CELSIUS) + +DOMAIN = "climate" + +ENTITY_ID_FORMAT = DOMAIN + ".{}" +SCAN_INTERVAL = 60 + +SERVICE_SET_AWAY_MODE = "set_away_mode" +SERVICE_SET_AUX_HEAT = "set_aux_heat" +SERVICE_SET_TEMPERATURE = "set_temperature" +SERVICE_SET_FAN_MODE = "set_fan_mode" +SERVICE_SET_OPERATION_MODE = "set_operation_mode" +SERVICE_SET_SWING_MODE = "set_swing_mode" +SERVICE_SET_HUMIDITY = "set_humidity" + +STATE_HEAT = "heat" +STATE_COOL = "cool" +STATE_IDLE = "idle" +STATE_AUTO = "auto" +STATE_DRY = "dry" +STATE_FAN_ONLY = "fan_only" + +ATTR_CURRENT_TEMPERATURE = "current_temperature" +ATTR_MAX_TEMP = "max_temp" +ATTR_MIN_TEMP = "min_temp" +ATTR_AWAY_MODE = "away_mode" +ATTR_AUX_HEAT = "aux_heat" +ATTR_FAN_MODE = "fan_mode" +ATTR_FAN_LIST = "fan_list" +ATTR_CURRENT_HUMIDITY = "current_humidity" +ATTR_HUMIDITY = "humidity" +ATTR_MAX_HUMIDITY = "max_humidity" +ATTR_MIN_HUMIDITY = "min_humidity" +ATTR_OPERATION_MODE = "operation_mode" +ATTR_OPERATION_LIST = "operation_list" +ATTR_SWING_MODE = "swing_mode" +ATTR_SWING_LIST = "swing_list" + +_LOGGER = logging.getLogger(__name__) + +SET_AWAY_MODE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_AWAY_MODE): cv.boolean, +}) +SET_AUX_HEAT_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_AUX_HEAT): cv.boolean, +}) +SET_TEMPERATURE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_TEMPERATURE): vol.Coerce(float), +}) +SET_FAN_MODE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_FAN_MODE): cv.string, +}) +SET_OPERATION_MODE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_OPERATION_MODE): cv.string, +}) +SET_HUMIDITY_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_HUMIDITY): vol.Coerce(float), +}) +SET_SWING_MODE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_SWING_MODE): cv.string, +}) + + +def set_away_mode(hass, away_mode, entity_id=None): + """Turn all or specified climate 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) + + +def set_aux_heat(hass, aux_heat, entity_id=None): + """Turn all or specified climate devices auxillary heater on.""" + data = { + ATTR_AUX_HEAT: aux_heat + } + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_SET_AUX_HEAT, data) + + +def set_temperature(hass, temperature, entity_id=None): + """Set new target temperature.""" + data = {ATTR_TEMPERATURE: temperature} + + if entity_id is not None: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_SET_TEMPERATURE, data) + + +def set_humidity(hass, humidity, entity_id=None): + """Set new target humidity.""" + data = {ATTR_HUMIDITY: humidity} + + if entity_id is not None: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_SET_HUMIDITY, data) + + +def set_fan_mode(hass, fan, entity_id=None): + """Set all or specified climate devices fan mode on.""" + data = {ATTR_FAN_MODE: fan} + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_SET_FAN_MODE, data) + + +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) + + +def set_swing_mode(hass, swing_mode, entity_id=None): + """Set new target swing mode.""" + data = {ATTR_SWING_MODE: swing_mode} + + if entity_id is not None: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_SET_SWING_MODE, data) + + +# pylint: disable=too-many-branches +def setup(hass, config): + """Setup climate devices.""" + component = EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) + component.setup(config) + + descriptions = load_yaml_config_file( + os.path.join(os.path.dirname(__file__), 'services.yaml')) + + def away_mode_set_service(service): + """Set away mode on target climate devices.""" + target_climate = component.extract_from_service(service) + + away_mode = service.data.get(ATTR_AWAY_MODE) + + if away_mode is None: + _LOGGER.error( + "Received call to %s without attribute %s", + SERVICE_SET_AWAY_MODE, ATTR_AWAY_MODE) + return + + for climate in target_climate: + if away_mode: + climate.turn_away_mode_on() + else: + climate.turn_away_mode_off() + + if climate.should_poll: + climate.update_ha_state(True) + + hass.services.register( + DOMAIN, SERVICE_SET_AWAY_MODE, away_mode_set_service, + descriptions.get(SERVICE_SET_AWAY_MODE), + schema=SET_AWAY_MODE_SCHEMA) + + def aux_heat_set_service(service): + """Set auxillary heater on target climate devices.""" + target_climate = component.extract_from_service(service) + + aux_heat = service.data.get(ATTR_AUX_HEAT) + + if aux_heat is None: + _LOGGER.error( + "Received call to %s without attribute %s", + SERVICE_SET_AUX_HEAT, ATTR_AUX_HEAT) + return + + for climate in target_climate: + if aux_heat: + climate.turn_aux_heat_on() + else: + climate.turn_aux_heat_off() + + if climate.should_poll: + climate.update_ha_state(True) + + hass.services.register( + DOMAIN, SERVICE_SET_AUX_HEAT, aux_heat_set_service, + descriptions.get(SERVICE_SET_AUX_HEAT), + schema=SET_AUX_HEAT_SCHEMA) + + def temperature_set_service(service): + """Set temperature on the target climate devices.""" + target_climate = component.extract_from_service(service) + + temperature = util.convert( + service.data.get(ATTR_TEMPERATURE), float) + + if temperature is None: + _LOGGER.error( + "Received call to %s without attribute %s", + SERVICE_SET_TEMPERATURE, ATTR_TEMPERATURE) + return + + for climate in target_climate: + climate.set_temperature(convert_temperature( + temperature, hass.config.units.temperature_unit, + climate.unit_of_measurement)) + + if climate.should_poll: + climate.update_ha_state(True) + + hass.services.register( + DOMAIN, SERVICE_SET_TEMPERATURE, temperature_set_service, + descriptions.get(SERVICE_SET_TEMPERATURE), + schema=SET_TEMPERATURE_SCHEMA) + + def humidity_set_service(service): + """Set humidity on the target climate devices.""" + target_climate = component.extract_from_service(service) + + humidity = service.data.get(ATTR_HUMIDITY) + + if humidity is None: + _LOGGER.error( + "Received call to %s without attribute %s", + SERVICE_SET_HUMIDITY, ATTR_HUMIDITY) + return + + for climate in target_climate: + climate.set_humidity(humidity) + + if climate.should_poll: + climate.update_ha_state(True) + + hass.services.register( + DOMAIN, SERVICE_SET_HUMIDITY, humidity_set_service, + descriptions.get(SERVICE_SET_HUMIDITY), + schema=SET_HUMIDITY_SCHEMA) + + def fan_mode_set_service(service): + """Set fan mode on target climate devices.""" + target_climate = component.extract_from_service(service) + + fan = service.data.get(ATTR_FAN_MODE) + + if fan is None: + _LOGGER.error( + "Received call to %s without attribute %s", + SERVICE_SET_FAN_MODE, ATTR_FAN_MODE) + return + + for climate in target_climate: + climate.set_fan_mode(fan) + + if climate.should_poll: + climate.update_ha_state(True) + + hass.services.register( + DOMAIN, SERVICE_SET_FAN_MODE, fan_mode_set_service, + descriptions.get(SERVICE_SET_FAN_MODE), + schema=SET_FAN_MODE_SCHEMA) + + def operation_set_service(service): + """Set operating mode on the target climate devices.""" + target_climate = component.extract_from_service(service) + + operation_mode = service.data.get(ATTR_OPERATION_MODE) + + if operation_mode is None: + _LOGGER.error( + "Received call to %s without attribute %s", + SERVICE_SET_OPERATION_MODE, ATTR_OPERATION_MODE) + return + + for climate in target_climate: + climate.set_operation_mode(operation_mode) + + if climate.should_poll: + climate.update_ha_state(True) + + hass.services.register( + DOMAIN, SERVICE_SET_OPERATION_MODE, operation_set_service, + descriptions.get(SERVICE_SET_OPERATION_MODE), + schema=SET_OPERATION_MODE_SCHEMA) + + def swing_set_service(service): + """Set swing mode on the target climate devices.""" + target_climate = component.extract_from_service(service) + + swing_mode = service.data.get(ATTR_SWING_MODE) + + if swing_mode is None: + _LOGGER.error( + "Received call to %s without attribute %s", + SERVICE_SET_SWING_MODE, ATTR_SWING_MODE) + return + + for climate in target_climate: + climate.set_swing_mode(swing_mode) + + if climate.should_poll: + climate.update_ha_state(True) + + hass.services.register( + DOMAIN, SERVICE_SET_SWING_MODE, swing_set_service, + descriptions.get(SERVICE_SET_SWING_MODE), + schema=SET_SWING_MODE_SCHEMA) + return True + + +class ClimateDevice(Entity): + """Representation of a climate device.""" + + # pylint: disable=too-many-public-methods,no-self-use + @property + def state(self): + """Return the current state.""" + return self.current_operation or STATE_UNKNOWN + + @property + def state_attributes(self): + """Return the optional state attributes.""" + data = { + ATTR_CURRENT_TEMPERATURE: + self._convert_for_display(self.current_temperature), + ATTR_MIN_TEMP: self._convert_for_display(self.min_temp), + ATTR_MAX_TEMP: self._convert_for_display(self.max_temp), + ATTR_TEMPERATURE: + self._convert_for_display(self.target_temperature), + } + + humidity = self.target_humidity + if humidity is not None: + data[ATTR_HUMIDITY] = humidity + data[ATTR_CURRENT_HUMIDITY] = self.current_humidity + data[ATTR_MIN_HUMIDITY] = self.min_humidity + data[ATTR_MAX_HUMIDITY] = self.max_humidity + + fan_mode = self.current_fan_mode + if fan_mode is not None: + data[ATTR_FAN_MODE] = fan_mode + data[ATTR_FAN_LIST] = self.fan_list + + operation_mode = self.current_operation + if operation_mode is not None: + data[ATTR_OPERATION_MODE] = operation_mode + data[ATTR_OPERATION_LIST] = self.operation_list + + swing_mode = self.current_swing_mode + if swing_mode is not None: + data[ATTR_SWING_MODE] = swing_mode + data[ATTR_SWING_LIST] = self.swing_list + + is_away = self.is_away_mode_on + if is_away is not None: + data[ATTR_AWAY_MODE] = STATE_ON if is_away else STATE_OFF + + is_aux_heat = self.is_aux_heat_on + if is_aux_heat is not None: + data[ATTR_AUX_HEAT] = STATE_ON if is_aux_heat else STATE_OFF + + return data + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + raise NotImplementedError + + @property + def current_humidity(self): + """Return the current humidity.""" + return None + + @property + def target_humidity(self): + """Return the humidity we try to reach.""" + return None + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + return None + + @property + def operation_list(self): + """List of available operation modes.""" + return None + + @property + def current_temperature(self): + """Return the current temperature.""" + 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 + + @property + def is_aux_heat_on(self): + """Return true if aux heater.""" + return None + + @property + def current_fan_mode(self): + """Return the fan setting.""" + return None + + @property + def fan_list(self): + """List of available fan modes.""" + return None + + @property + def current_swing_mode(self): + """Return the fan setting.""" + return None + + @property + def swing_list(self): + """List of available swing modes.""" + return None + + def set_temperature(self, temperature): + """Set new target temperature.""" + raise NotImplementedError() + + def set_humidity(self, humidity): + """Set new target humidity.""" + raise NotImplementedError() + + def set_fan_mode(self, fan): + """Set new target fan mode.""" + raise NotImplementedError() + + def set_operation_mode(self, operation_mode): + """Set new target operation mode.""" + raise NotImplementedError() + + def set_swing_mode(self, swing_mode): + """Set new target swing operation.""" + raise NotImplementedError() + + def turn_away_mode_on(self): + """Turn away mode on.""" + raise NotImplementedError() + + def turn_away_mode_off(self): + """Turn away mode off.""" + raise NotImplementedError() + + def turn_aux_heat_on(self): + """Turn auxillary heater on.""" + raise NotImplementedError() + + def turn_aux_heat_off(self): + """Turn auxillary heater off.""" + raise NotImplementedError() + + @property + def min_temp(self): + """Return the minimum temperature.""" + return convert_temperature(7, TEMP_CELSIUS, self.unit_of_measurement) + + @property + def max_temp(self): + """Return the maximum temperature.""" + return convert_temperature(35, TEMP_CELSIUS, self.unit_of_measurement) + + @property + def min_humidity(self): + """Return the minimum humidity.""" + return 30 + + @property + def max_humidity(self): + """Return the maximum humidity.""" + return 99 + + def _convert_for_display(self, temp): + """Convert temperature into preferred units for display purposes.""" + if temp is None or not isinstance(temp, Number): + return temp + + value = convert_temperature(temp, self.unit_of_measurement, + self.hass.config.units.temperature_unit) + + if self.hass.config.units.temperature_unit is TEMP_CELSIUS: + decimal_count = 1 + else: + # Users of fahrenheit generally expect integer units. + decimal_count = 0 + + return round(value, decimal_count) diff --git a/homeassistant/components/climate/demo.py b/homeassistant/components/climate/demo.py new file mode 100644 index 00000000000..445a568d2f0 --- /dev/null +++ b/homeassistant/components/climate/demo.py @@ -0,0 +1,164 @@ +""" +Demo platform that offers a fake climate device. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/demo/ +""" +from homeassistant.components.climate import ClimateDevice +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Demo climate devices.""" + add_devices([ + DemoClimate("HeatPump", 68, TEMP_FAHRENHEIT, None, 77, "Auto Low", + None, None, "Auto", "Heat", None), + DemoClimate("Hvac", 21, TEMP_CELSIUS, True, 22, "On High", + 67, 54, "Off", "Cool", False), + ]) + + +# pylint: disable=too-many-arguments, too-many-public-methods +class DemoClimate(ClimateDevice): + """Representation of a demo climate device.""" + + # pylint: disable=too-many-instance-attributes + def __init__(self, name, target_temperature, unit_of_measurement, + away, current_temperature, current_fan_mode, + target_humidity, current_humidity, current_swing_mode, + current_operation, aux): + """Initialize the climate device.""" + self._name = name + self._target_temperature = target_temperature + self._target_humidity = target_humidity + self._unit_of_measurement = unit_of_measurement + self._away = away + self._current_temperature = current_temperature + self._current_humidity = current_humidity + self._current_fan_mode = current_fan_mode + self._current_operation = current_operation + self._aux = aux + self._current_swing_mode = current_swing_mode + self._fan_list = ["On Low", "On High", "Auto Low", "Auto High", "Off"] + self._operation_list = ["Heat", "Cool", "Auto Changeover", "Off"] + self._swing_list = ["Auto", 1, 2, 3, "Off"] + + @property + def should_poll(self): + """Polling not needed for a demo climate device.""" + return False + + @property + def name(self): + """Return the name of the climate device.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + @property + def current_humidity(self): + """Return the current humidity.""" + return self._current_humidity + + @property + def target_humidity(self): + """Return the humidity we try to reach.""" + return self._target_humidity + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + return self._current_operation + + @property + def operation_list(self): + """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 + + @property + def is_aux_heat_on(self): + """Return true if away mode is on.""" + return self._aux + + @property + def current_fan_mode(self): + """Return the fan setting.""" + return self._current_fan_mode + + @property + def fan_list(self): + """List of available fan modes.""" + return self._fan_list + + def set_temperature(self, temperature): + """Set new target temperature.""" + self._target_temperature = temperature + self.update_ha_state() + + def set_humidity(self, humidity): + """Set new target temperature.""" + self._target_humidity = humidity + self.update_ha_state() + + def set_swing_mode(self, swing_mode): + """Set new target temperature.""" + self._current_swing_mode = swing_mode + self.update_ha_state() + + def set_fan_mode(self, fan): + """Set new target temperature.""" + self._current_fan_mode = fan + self.update_ha_state() + + def set_operation_mode(self, operation_mode): + """Set new target temperature.""" + self._current_operation = operation_mode + self.update_ha_state() + + @property + def current_swing_mode(self): + """Return the swing setting.""" + return self._current_swing_mode + + @property + def swing_list(self): + """List of available swing modes.""" + return self._swing_list + + def turn_away_mode_on(self): + """Turn away mode on.""" + self._away = True + self.update_ha_state() + + def turn_away_mode_off(self): + """Turn away mode off.""" + self._away = False + self.update_ha_state() + + def turn_aux_heat_on(self): + """Turn away auxillary heater on.""" + self._aux = True + self.update_ha_state() + + def turn_aux_heat_off(self): + """Turn auxillary heater off.""" + self._aux = False + self.update_ha_state() diff --git a/homeassistant/components/climate/ecobee.py b/homeassistant/components/climate/ecobee.py new file mode 100644 index 00000000000..76038085385 --- /dev/null +++ b/homeassistant/components/climate/ecobee.py @@ -0,0 +1,247 @@ +""" +Platform for Ecobee Thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.ecobee/ +""" +import logging +from os import path +import voluptuous as vol + +from homeassistant.components import ecobee +from homeassistant.components.climate import ( + DOMAIN, STATE_COOL, STATE_HEAT, STATE_IDLE, ClimateDevice) +from homeassistant.const import ( + ATTR_ENTITY_ID, STATE_OFF, STATE_ON, TEMP_FAHRENHEIT) +from homeassistant.config import load_yaml_config_file +import homeassistant.helpers.config_validation as cv + +DEPENDENCIES = ['ecobee'] +_LOGGER = logging.getLogger(__name__) +ECOBEE_CONFIG_FILE = 'ecobee.conf' +_CONFIGURING = {} + +ATTR_FAN_MIN_ON_TIME = "fan_min_on_time" +SERVICE_SET_FAN_MIN_ON_TIME = "ecobee_set_fan_min_on_time" +SET_FAN_MIN_ON_TIME_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_FAN_MIN_ON_TIME): vol.Coerce(int), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Ecobee Thermostat Platform.""" + if discovery_info is None: + return + data = ecobee.NETWORK + hold_temp = discovery_info['hold_temp'] + _LOGGER.info( + "Loading ecobee thermostat component with hold_temp set to %s", + hold_temp) + devices = [Thermostat(data, index, hold_temp) + for index in range(len(data.ecobee.thermostats))] + add_devices(devices) + + def fan_min_on_time_set_service(service): + """Set the minimum fan on time on the target thermostats.""" + entity_id = service.data.get('entity_id') + + if entity_id: + target_thermostats = [device for device in devices + if device.entity_id == entity_id] + else: + target_thermostats = devices + + fan_min_on_time = service.data[ATTR_FAN_MIN_ON_TIME] + + for thermostat in target_thermostats: + thermostat.set_fan_min_on_time(str(fan_min_on_time)) + + thermostat.update_ha_state(True) + + descriptions = load_yaml_config_file( + path.join(path.dirname(__file__), 'services.yaml')) + + hass.services.register( + DOMAIN, SERVICE_SET_FAN_MIN_ON_TIME, fan_min_on_time_set_service, + descriptions.get(SERVICE_SET_FAN_MIN_ON_TIME), + schema=SET_FAN_MIN_ON_TIME_SCHEMA) + + +# pylint: disable=too-many-public-methods, abstract-method +class Thermostat(ClimateDevice): + """A thermostat class for Ecobee.""" + + def __init__(self, data, thermostat_index, hold_temp): + """Initialize the thermostat.""" + self.data = data + self.thermostat_index = thermostat_index + self.thermostat = self.data.ecobee.get_thermostat( + self.thermostat_index) + self._name = self.thermostat['name'] + self.hold_temp = hold_temp + + def update(self): + """Get the latest state from the thermostat.""" + self.data.update() + self.thermostat = self.data.ecobee.get_thermostat( + self.thermostat_index) + + @property + def name(self): + """Return the name of the Ecobee Thermostat.""" + return self.thermostat['name'] + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return TEMP_FAHRENHEIT + + @property + def current_temperature(self): + """Return the current temperature.""" + return self.thermostat['runtime']['actualTemperature'] / 10 + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + if (self.operation_mode == 'heat' or + self.operation_mode == 'auxHeatOnly'): + return self.target_temperature_low + elif self.operation_mode == 'cool': + return self.target_temperature_high + else: + return (self.target_temperature_low + + self.target_temperature_high) / 2 + + @property + def target_temperature_low(self): + """Return the lower bound temperature we try to reach.""" + return int(self.thermostat['runtime']['desiredHeat'] / 10) + + @property + def target_temperature_high(self): + """Return the upper bound temperature we try to reach.""" + return int(self.thermostat['runtime']['desiredCool'] / 10) + + @property + def current_humidity(self): + """Return the current humidity.""" + return self.thermostat['runtime']['actualHumidity'] + + @property + def desired_fan_mode(self): + """Return the desired fan mode of operation.""" + return self.thermostat['runtime']['desiredFanMode'] + + @property + def fan(self): + """Return the current fan state.""" + if 'fan' in self.thermostat['equipmentStatus']: + return STATE_ON + else: + return STATE_OFF + + @property + def operation_mode(self): + """Return current operation ie. heat, cool, idle.""" + status = self.thermostat['equipmentStatus'] + if status == '': + return STATE_IDLE + elif 'Cool' in status: + return STATE_COOL + elif 'auxHeat' in status: + return STATE_HEAT + elif 'heatPump' in status: + return STATE_HEAT + else: + return status + + @property + def mode(self): + """Return current mode ie. home, away, sleep.""" + return self.thermostat['program']['currentClimateRef'] + + @property + def current_operation(self): + """Return current hvac mode ie. auto, auxHeatOnly, cool, heat, off.""" + return self.thermostat['settings']['hvacMode'] + + @property + def fan_min_on_time(self): + """Return current fan minimum on time.""" + return self.thermostat['settings']['fanMinOnTime'] + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + # Move these to Thermostat Device and make them global + return { + "humidity": self.current_humidity, + "fan": self.fan, + "mode": self.mode, + "operation_mode": self.current_operation, + "fan_min_on_time": self.fan_min_on_time + } + + @property + def is_away_mode_on(self): + """Return true if away mode is on.""" + mode = self.mode + events = self.thermostat['events'] + for event in events: + if event['running']: + mode = event['holdClimateRef'] + break + return 'away' in mode + + def turn_away_mode_on(self): + """Turn away on.""" + if self.hold_temp: + self.data.ecobee.set_climate_hold(self.thermostat_index, + "away", "indefinite") + else: + self.data.ecobee.set_climate_hold(self.thermostat_index, "away") + + def turn_away_mode_off(self): + """Turn away off.""" + self.data.ecobee.resume_program(self.thermostat_index) + + def set_temperature(self, temperature): + """Set new target temperature.""" + temperature = int(temperature) + low_temp = temperature - 1 + high_temp = temperature + 1 + if self.hold_temp: + self.data.ecobee.set_hold_temp(self.thermostat_index, low_temp, + high_temp, "indefinite") + else: + self.data.ecobee.set_hold_temp(self.thermostat_index, low_temp, + high_temp) + + def set_operation_mode(self, operation_mode): + """Set HVAC mode (auto, auxHeatOnly, cool, heat, off).""" + self.data.ecobee.set_hvac_mode(self.thermostat_index, operation_mode) + + def set_fan_min_on_time(self, fan_min_on_time): + """Set the minimum fan on time.""" + self.data.ecobee.set_fan_min_on_time(self.thermostat_index, + fan_min_on_time) + + # Home and Sleep mode aren't used in UI yet: + + # def turn_home_mode_on(self): + # """ Turns home mode on. """ + # self.data.ecobee.set_climate_hold(self.thermostat_index, "home") + + # def turn_home_mode_off(self): + # """ Turns home mode off. """ + # self.data.ecobee.resume_program(self.thermostat_index) + + # def turn_sleep_mode_on(self): + # """ Turns sleep mode on. """ + # self.data.ecobee.set_climate_hold(self.thermostat_index, "sleep") + + # def turn_sleep_mode_off(self): + # """ Turns sleep mode off. """ + # self.data.ecobee.resume_program(self.thermostat_index) diff --git a/homeassistant/components/climate/eq3btsmart.py b/homeassistant/components/climate/eq3btsmart.py new file mode 100644 index 00000000000..01114972811 --- /dev/null +++ b/homeassistant/components/climate/eq3btsmart.py @@ -0,0 +1,90 @@ +""" +Support for eq3 Bluetooth Smart thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.eq3btsmart/ +""" +import logging + +from homeassistant.components.climate import ClimateDevice +from homeassistant.const import TEMP_CELSIUS +from homeassistant.util.temperature import convert + +REQUIREMENTS = ['bluepy_devices==0.2.0'] + +CONF_MAC = 'mac' +CONF_DEVICES = 'devices' +CONF_ID = 'id' + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the eq3 BLE thermostats.""" + devices = [] + + for name, device_cfg in config[CONF_DEVICES].items(): + mac = device_cfg[CONF_MAC] + devices.append(EQ3BTSmartThermostat(mac, name)) + + add_devices(devices) + return True + + +# pylint: disable=too-many-instance-attributes, import-error, abstract-method +class EQ3BTSmartThermostat(ClimateDevice): + """Representation of a EQ3 Bluetooth Smart thermostat.""" + + def __init__(self, _mac, _name): + """Initialize the thermostat.""" + from bluepy_devices.devices import eq3btsmart + + self._name = _name + + self._thermostat = eq3btsmart.EQ3BTSmartThermostat(_mac) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement that is used.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Can not report temperature, so return target_temperature.""" + return self.target_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._thermostat.target_temperature + + def set_temperature(self, temperature): + """Set new target temperature.""" + self._thermostat.target_temperature = temperature + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + return {"mode": self._thermostat.mode, + "mode_readable": self._thermostat.mode_readable} + + @property + def min_temp(self): + """Return the minimum temperature.""" + return convert(self._thermostat.min_temp, TEMP_CELSIUS, + self.unit_of_measurement) + + @property + def max_temp(self): + """Return the maximum temperature.""" + return convert(self._thermostat.max_temp, TEMP_CELSIUS, + self.unit_of_measurement) + + def update(self): + """Update the data from the thermostat.""" + self._thermostat.update() diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py new file mode 100644 index 00000000000..11e6707ad47 --- /dev/null +++ b/homeassistant/components/climate/generic_thermostat.py @@ -0,0 +1,216 @@ +""" +Adds support for generic thermostat units. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.generic_thermostat/ +""" +import logging +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components import switch +from homeassistant.components.climate import ( + STATE_HEAT, STATE_COOL, STATE_IDLE, ClimateDevice) +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, STATE_ON, STATE_OFF +from homeassistant.helpers import condition +from homeassistant.helpers.event import track_state_change + +DEPENDENCIES = ['switch', 'sensor'] + +TOL_TEMP = 0.3 + +CONF_NAME = 'name' +DEFAULT_NAME = 'Generic Thermostat' +CONF_HEATER = 'heater' +CONF_SENSOR = 'target_sensor' +CONF_MIN_TEMP = 'min_temp' +CONF_MAX_TEMP = 'max_temp' +CONF_TARGET_TEMP = 'target_temp' +CONF_AC_MODE = 'ac_mode' +CONF_MIN_DUR = 'min_cycle_duration' + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required("platform"): "generic_thermostat", + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_HEATER): cv.entity_id, + vol.Required(CONF_SENSOR): cv.entity_id, + vol.Optional(CONF_MIN_TEMP): vol.Coerce(float), + vol.Optional(CONF_MAX_TEMP): vol.Coerce(float), + vol.Optional(CONF_TARGET_TEMP): vol.Coerce(float), + vol.Optional(CONF_AC_MODE): vol.Coerce(bool), + vol.Optional(CONF_MIN_DUR): vol.All(cv.time_period, cv.positive_timedelta), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the generic thermostat.""" + name = config.get(CONF_NAME) + heater_entity_id = config.get(CONF_HEATER) + sensor_entity_id = config.get(CONF_SENSOR) + min_temp = config.get(CONF_MIN_TEMP) + max_temp = config.get(CONF_MAX_TEMP) + target_temp = config.get(CONF_TARGET_TEMP) + ac_mode = config.get(CONF_AC_MODE) + min_cycle_duration = config.get(CONF_MIN_DUR) + + add_devices([GenericThermostat(hass, name, heater_entity_id, + sensor_entity_id, min_temp, + max_temp, target_temp, ac_mode, + min_cycle_duration)]) + + +# pylint: disable=too-many-instance-attributes, abstract-method +class GenericThermostat(ClimateDevice): + """Representation of a GenericThermostat device.""" + + # pylint: disable=too-many-arguments + def __init__(self, hass, name, heater_entity_id, sensor_entity_id, + min_temp, max_temp, target_temp, ac_mode, min_cycle_duration): + """Initialize the thermostat.""" + self.hass = hass + self._name = name + self.heater_entity_id = heater_entity_id + self.ac_mode = ac_mode + self.min_cycle_duration = min_cycle_duration + + self._active = False + self._cur_temp = None + self._min_temp = min_temp + self._max_temp = max_temp + self._target_temp = target_temp + self._unit = hass.config.units.temperature_unit + + track_state_change(hass, sensor_entity_id, self._sensor_changed) + + sensor_state = hass.states.get(sensor_entity_id) + if sensor_state: + self._update_temp(sensor_state) + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return the name of the thermostat.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + @property + def current_temperature(self): + """Return the sensor temperature.""" + return self._cur_temp + + @property + def operation(self): + """Return current operation ie. heat, cool, idle.""" + if self.ac_mode: + cooling = self._active and self._is_device_active + return STATE_COOL if cooling else STATE_IDLE + else: + heating = self._active and self._is_device_active + return STATE_HEAT if heating else STATE_IDLE + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temp + + def set_temperature(self, temperature): + """Set new target temperature.""" + self._target_temp = temperature + self._control_heating() + self.update_ha_state() + + @property + def min_temp(self): + """Return the minimum temperature.""" + # pylint: disable=no-member + if self._min_temp: + return self._min_temp + else: + # get default temp from super class + return ClimateDevice.min_temp.fget(self) + + @property + def max_temp(self): + """Return the maximum temperature.""" + # pylint: disable=no-member + if self._min_temp: + return self._max_temp + else: + # Get default temp from super class + return ClimateDevice.max_temp.fget(self) + + def _sensor_changed(self, entity_id, old_state, new_state): + """Called when temperature changes.""" + if new_state is None: + return + + self._update_temp(new_state) + self._control_heating() + self.update_ha_state() + + def _update_temp(self, state): + """Update thermostat with latest state from sensor.""" + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + + try: + self._cur_temp = self.hass.config.units.temperature( + float(state.state), unit) + except ValueError as ex: + _LOGGER.error('Unable to update from sensor: %s', ex) + + def _control_heating(self): + """Check if we need to turn heating on or off.""" + if not self._active and None not in (self._cur_temp, + self._target_temp): + self._active = True + _LOGGER.info('Obtained current and target temperature. ' + 'Generic thermostat active.') + + if not self._active: + return + + if self.min_cycle_duration: + if self._is_device_active: + current_state = STATE_ON + else: + current_state = STATE_OFF + long_enough = condition.state(self.hass, self.heater_entity_id, + current_state, + self.min_cycle_duration) + if not long_enough: + return + + if self.ac_mode: + too_hot = self._cur_temp - self._target_temp > TOL_TEMP + is_cooling = self._is_device_active + if too_hot and not is_cooling: + _LOGGER.info('Turning on AC %s', self.heater_entity_id) + switch.turn_on(self.hass, self.heater_entity_id) + elif not too_hot and is_cooling: + _LOGGER.info('Turning off AC %s', self.heater_entity_id) + switch.turn_off(self.hass, self.heater_entity_id) + else: + too_cold = self._target_temp - self._cur_temp > TOL_TEMP + is_heating = self._is_device_active + + if too_cold and not is_heating: + _LOGGER.info('Turning on heater %s', self.heater_entity_id) + switch.turn_on(self.hass, self.heater_entity_id) + elif not too_cold and is_heating: + _LOGGER.info('Turning off heater %s', self.heater_entity_id) + switch.turn_off(self.hass, self.heater_entity_id) + + @property + def _is_device_active(self): + """If the toggleable device is currently active.""" + return switch.is_on(self.hass, self.heater_entity_id) diff --git a/homeassistant/components/climate/heatmiser.py b/homeassistant/components/climate/heatmiser.py new file mode 100644 index 00000000000..c7dd5534f57 --- /dev/null +++ b/homeassistant/components/climate/heatmiser.py @@ -0,0 +1,114 @@ +""" +Support for the PRT Heatmiser themostats using the V3 protocol. + +See https://github.com/andylockran/heatmiserV3 for more info on the +heatmiserV3 module dependency. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.heatmiser/ +""" +import logging + +from homeassistant.components.climate import ClimateDevice +from homeassistant.const import TEMP_CELSIUS + +CONF_IPADDRESS = 'ipaddress' +CONF_PORT = 'port' +CONF_TSTATS = 'tstats' + +REQUIREMENTS = ["heatmiserV3==0.9.1"] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the heatmiser thermostat.""" + from heatmiserV3 import heatmiser, connection + + ipaddress = str(config[CONF_IPADDRESS]) + port = str(config[CONF_PORT]) + + if ipaddress is None or port is None: + _LOGGER.error("Missing required configuration items %s or %s", + CONF_IPADDRESS, CONF_PORT) + return False + + serport = connection.connection(ipaddress, port) + serport.open() + + tstats = [] + if CONF_TSTATS in config: + tstats = config[CONF_TSTATS] + + if tstats is None: + _LOGGER.error("No thermostats configured.") + return False + + for tstat in tstats: + add_devices([ + HeatmiserV3Thermostat( + heatmiser, + tstat.get("id"), + tstat.get("name"), + serport) + ]) + return + + +class HeatmiserV3Thermostat(ClimateDevice): + """Representation of a HeatmiserV3 thermostat.""" + + # pylint: disable=too-many-instance-attributes, abstract-method + def __init__(self, heatmiser, device, name, serport): + """Initialize the thermostat.""" + self.heatmiser = heatmiser + self.device = device + self.serport = serport + self._current_temperature = None + self._name = name + self._id = device + self.dcb = None + self.update() + self._target_temperature = int(self.dcb.get("roomset")) + + @property + def name(self): + """Return the name of the thermostat, if any.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement which this thermostat uses.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + if self.dcb is not None: + low = self.dcb.get("floortemplow ") + high = self.dcb.get("floortemphigh") + temp = (high*256 + low)/10.0 + self._current_temperature = temp + else: + self._current_temperature = None + return self._current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + def set_temperature(self, temperature): + """Set new target temperature.""" + temperature = int(temperature) + self.heatmiser.hmSendAddress( + self._id, + 18, + temperature, + 1, + self.serport) + self._target_temperature = int(temperature) + + def update(self): + """Get the latest data.""" + self.dcb = self.heatmiser.hmReadAddress(self._id, 'prt', self.serport) diff --git a/homeassistant/components/climate/homematic.py b/homeassistant/components/climate/homematic.py new file mode 100644 index 00000000000..da160bab56e --- /dev/null +++ b/homeassistant/components/climate/homematic.py @@ -0,0 +1,90 @@ +""" +Support for Homematic thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.homematic/ +""" +import logging +import homeassistant.components.homematic as homematic +from homeassistant.components.climate import ClimateDevice +from homeassistant.util.temperature import convert +from homeassistant.const import TEMP_CELSIUS, STATE_UNKNOWN + +DEPENDENCIES = ['homematic'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_callback_devices, discovery_info=None): + """Setup the Homematic thermostat platform.""" + if discovery_info is None: + return + + return homematic.setup_hmdevice_discovery_helper(HMThermostat, + discovery_info, + add_callback_devices) + + +# pylint: disable=abstract-method +class HMThermostat(homematic.HMDevice, ClimateDevice): + """Representation of a Homematic thermostat.""" + + @property + def unit_of_measurement(self): + """Return the unit of measurement that is used.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + if not self.available: + return None + return self._data["ACTUAL_TEMPERATURE"] + + @property + def target_temperature(self): + """Return the target temperature.""" + if not self.available: + return None + return self._data["SET_TEMPERATURE"] + + def set_temperature(self, temperature): + """Set new target temperature.""" + if not self.available: + return None + self._hmdevice.set_temperature(temperature) + + @property + def min_temp(self): + """Return the minimum temperature - 4.5 means off.""" + return convert(4.5, TEMP_CELSIUS, self.unit_of_measurement) + + @property + def max_temp(self): + """Return the maximum temperature - 30.5 means on.""" + return convert(30.5, TEMP_CELSIUS, self.unit_of_measurement) + + def _check_hm_to_ha_object(self): + """Check if possible to use the Homematic object as this HA type.""" + from pyhomematic.devicetypes.thermostats import HMThermostat\ + as pyHMThermostat + + # Check compatibility from HMDevice + if not super()._check_hm_to_ha_object(): + return False + + # Check if the Homematic device correct for this HA device + if isinstance(self._hmdevice, pyHMThermostat): + return True + + _LOGGER.critical("This %s can't be use as thermostat", self._name) + return False + + def _init_data_struct(self): + """Generate a data dict (self._data) from the Homematic metadata.""" + super()._init_data_struct() + + # Add state to data dict + self._data.update({"CONTROL_MODE": STATE_UNKNOWN, + "SET_TEMPERATURE": STATE_UNKNOWN, + "ACTUAL_TEMPERATURE": STATE_UNKNOWN}) diff --git a/homeassistant/components/climate/honeywell.py b/homeassistant/components/climate/honeywell.py new file mode 100644 index 00000000000..1efce2b95de --- /dev/null +++ b/homeassistant/components/climate/honeywell.py @@ -0,0 +1,266 @@ +""" +Support for Honeywell Round Connected and Honeywell Evohome thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.honeywell/ +""" +import logging +import socket + +from homeassistant.components.climate import ClimateDevice +from homeassistant.const import ( + CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT) + +REQUIREMENTS = ['evohomeclient==0.2.5', + 'somecomfort==0.2.1'] + +_LOGGER = logging.getLogger(__name__) + +CONF_AWAY_TEMP = "away_temperature" +DEFAULT_AWAY_TEMP = 16 + + +def _setup_round(username, password, config, add_devices): + """Setup rounding function.""" + from evohomeclient import EvohomeClient + + try: + away_temp = float(config.get(CONF_AWAY_TEMP, DEFAULT_AWAY_TEMP)) + except ValueError: + _LOGGER.error("value entered for item %s should convert to a number", + CONF_AWAY_TEMP) + return False + + evo_api = EvohomeClient(username, password) + + try: + zones = evo_api.temperatures(force_refresh=True) + for i, zone in enumerate(zones): + add_devices([RoundThermostat(evo_api, + zone['id'], + i == 0, + away_temp)]) + except socket.error: + _LOGGER.error( + "Connection error logging into the honeywell evohome web service" + ) + return False + return True + + +# config will be used later +def _setup_us(username, password, config, add_devices): + """Setup user.""" + import somecomfort + + try: + client = somecomfort.SomeComfort(username, password) + except somecomfort.AuthError: + _LOGGER.error('Failed to login to honeywell account %s', username) + return False + except somecomfort.SomeComfortError as ex: + _LOGGER.error('Failed to initialize honeywell client: %s', str(ex)) + return False + + dev_id = config.get('thermostat') + loc_id = config.get('location') + + add_devices([HoneywellUSThermostat(client, device) + for location in client.locations_by_id.values() + for device in location.devices_by_id.values() + if ((not loc_id or location.locationid == loc_id) and + (not dev_id or device.deviceid == dev_id))]) + return True + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the honeywel thermostat.""" + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + region = config.get('region', 'eu').lower() + + if username is None or password is None: + _LOGGER.error("Missing required configuration items %s or %s", + CONF_USERNAME, CONF_PASSWORD) + return False + if region not in ('us', 'eu'): + _LOGGER.error('Region `%s` is invalid (use either us or eu)', region) + return False + + if region == 'us': + return _setup_us(username, password, config, add_devices) + else: + return _setup_round(username, password, config, add_devices) + + +class RoundThermostat(ClimateDevice): + """Representation of a Honeywell Round Connected thermostat.""" + + # pylint: disable=too-many-instance-attributes, abstract-method + def __init__(self, device, zone_id, master, away_temp): + """Initialize the thermostat.""" + self.device = device + self._current_temperature = None + self._target_temperature = None + self._name = "round connected" + self._id = zone_id + self._master = master + self._is_dhw = False + self._away_temp = away_temp + self._away = False + self.update() + + @property + def name(self): + """Return the name of the honeywell, if any.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + if self._is_dhw: + return None + return self._target_temperature + + def set_temperature(self, temperature): + """Set new target temperature.""" + self.device.set_temperature(self._name, temperature) + + @property + def current_operation(self: ClimateDevice) -> str: + """Get the current operation of the system.""" + return getattr(self.device, 'system_mode', None) + + @property + def is_away_mode_on(self): + """Return true if away mode is on.""" + return self._away + + def set_operation_mode(self: ClimateDevice, operation_mode: str) -> None: + """Set the HVAC mode for the thermostat.""" + if hasattr(self.device, 'system_mode'): + self.device.system_mode = operation_mode + + def turn_away_mode_on(self): + """Turn away on. + + Evohome does have a proprietary away mode, but it doesn't really work + the way it should. For example: If you set a temperature manually + it doesn't get overwritten when away mode is switched on. + """ + self._away = True + self.device.set_temperature(self._name, self._away_temp) + + def turn_away_mode_off(self): + """Turn away off.""" + self._away = False + self.device.cancel_temp_override(self._name) + + def update(self): + """Get the latest date.""" + try: + # Only refresh if this is the "master" device, + # others will pick up the cache + for val in self.device.temperatures(force_refresh=self._master): + if val['id'] == self._id: + data = val + + except StopIteration: + _LOGGER.error("Did not receive any temperature data from the " + "evohomeclient API.") + return + + self._current_temperature = data['temp'] + self._target_temperature = data['setpoint'] + if data['thermostat'] == "DOMESTIC_HOT_WATER": + self._name = "Hot Water" + self._is_dhw = True + else: + self._name = data['name'] + self._is_dhw = False + + +# pylint: disable=abstract-method +class HoneywellUSThermostat(ClimateDevice): + """Representation of a Honeywell US Thermostat.""" + + def __init__(self, client, device): + """Initialize the thermostat.""" + self._client = client + self._device = device + + @property + def is_fan_on(self): + """Return true if fan is on.""" + return self._device.fan_running + + @property + def name(self): + """Return the name of the honeywell, if any.""" + return self._device.name + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return (TEMP_CELSIUS if self._device.temperature_unit == 'C' + else TEMP_FAHRENHEIT) + + @property + def current_temperature(self): + """Return the current temperature.""" + self._device.refresh() + return self._device.current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + if self._device.system_mode == 'cool': + return self._device.setpoint_cool + else: + return self._device.setpoint_heat + + @property + def current_operation(self: ClimateDevice) -> str: + """Return current operation ie. heat, cool, idle.""" + return getattr(self._device, 'system_mode', None) + + def set_temperature(self, temperature): + """Set target temperature.""" + import somecomfort + try: + if self._device.system_mode == 'cool': + self._device.setpoint_cool = temperature + else: + self._device.setpoint_heat = temperature + except somecomfort.SomeComfortError: + _LOGGER.error('Temperature %.1f out of range', temperature) + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + return {'fan': (self.is_fan_on and 'running' or 'idle'), + 'fanmode': self._device.fan_mode, + 'system_mode': self._device.system_mode} + + def turn_away_mode_on(self): + """Turn away on.""" + pass + + def turn_away_mode_off(self): + """Turn away off.""" + pass + + def set_operation_mode(self: ClimateDevice, operation_mode: str) -> None: + """Set the system mode (Cool, Heat, etc).""" + if hasattr(self._device, 'system_mode'): + self._device.system_mode = operation_mode diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py new file mode 100644 index 00000000000..10f02d80cc7 --- /dev/null +++ b/homeassistant/components/climate/knx.py @@ -0,0 +1,83 @@ +""" +Support for KNX thermostats. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/knx/ +""" +import logging + +from homeassistant.components.climate import ClimateDevice +from homeassistant.const import TEMP_CELSIUS + +from homeassistant.components.knx import ( + KNXConfig, KNXMultiAddressDevice) + +DEPENDENCIES = ["knx"] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Create and add an entity based on the configuration.""" + add_entities([ + KNXThermostat(hass, KNXConfig(config)) + ]) + + +class KNXThermostat(KNXMultiAddressDevice, ClimateDevice): + """Representation of a KNX thermostat. + + A KNX thermostat will has the following parameters: + - temperature (current temperature) + - setpoint (target temperature in HASS terms) + - operation mode selection (comfort/night/frost protection) + + This version supports only polling. Messages from the KNX bus do not + automatically update the state of the thermostat (to be implemented + in future releases) + """ + + def __init__(self, hass, config): + """Initialize the thermostat based on the given configuration.""" + KNXMultiAddressDevice.__init__(self, hass, config, + ["temperature", "setpoint"], + ["mode"]) + + self._unit_of_measurement = TEMP_CELSIUS # KNX always used celsius + self._away = False # not yet supported + self._is_fan_on = False # not yet supported + + @property + def should_poll(self): + """Polling is needed for the KNX thermostat.""" + return True + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @property + def current_temperature(self): + """Return the current temperature.""" + from knxip.conversion import knx2_to_float + + return knx2_to_float(self.value("temperature")) + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + from knxip.conversion import knx2_to_float + + return knx2_to_float(self.value("setpoint")) + + def set_temperature(self, temperature): + """Set new target temperature.""" + from knxip.conversion import float_to_knx2 + + self.set_value("setpoint", float_to_knx2(temperature)) + _LOGGER.debug("Set target temperature to %s", temperature) + + def set_operation_mode(self, operation_mode): + """Set operation mode.""" + raise NotImplementedError() diff --git a/homeassistant/components/climate/nest.py b/homeassistant/components/climate/nest.py new file mode 100644 index 00000000000..39746bff601 --- /dev/null +++ b/homeassistant/components/climate/nest.py @@ -0,0 +1,189 @@ +""" +Support for Nest thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.nest/ +""" +import voluptuous as vol + +import homeassistant.components.nest as nest +from homeassistant.components.climate import ( + STATE_COOL, STATE_HEAT, STATE_IDLE, ClimateDevice) +from homeassistant.const import TEMP_CELSIUS, CONF_PLATFORM, CONF_SCAN_INTERVAL + +DEPENDENCIES = ['nest'] + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): nest.DOMAIN, + vol.Optional(CONF_SCAN_INTERVAL): + vol.All(vol.Coerce(int), vol.Range(min=1)), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Nest thermostat.""" + add_devices([NestThermostat(structure, device) + for structure, device in nest.devices()]) + + +# pylint: disable=abstract-method +class NestThermostat(ClimateDevice): + """Representation of a Nest thermostat.""" + + def __init__(self, structure, device): + """Initialize the thermostat.""" + self.structure = structure + self.device = device + + @property + def name(self): + """Return the name of the nest, if any.""" + location = self.device.where + name = self.device.name + if location is None: + return name + else: + if name == '': + return location.capitalize() + else: + return location.capitalize() + '(' + name + ')' + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + # Move these to Thermostat Device and make them global + return { + "humidity": self.device.humidity, + "target_humidity": self.device.target_humidity, + "mode": self.device.mode + } + + @property + def current_temperature(self): + """Return the current temperature.""" + return self.device.temperature + + @property + def operation(self): + """Return current operation ie. heat, cool, idle.""" + if self.device.hvac_ac_state is True: + return STATE_COOL + elif self.device.hvac_heater_state is True: + return STATE_HEAT + else: + return STATE_IDLE + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + if self.device.mode == 'range': + low, high = self.target_temperature_low, \ + self.target_temperature_high + if self.operation == STATE_COOL: + temp = high + elif self.operation == STATE_HEAT: + temp = low + else: + # If the outside temp is lower than the current temp, consider + # the 'low' temp to the target, otherwise use the high temp + if (self.device.structure.weather.current.temperature < + self.current_temperature): + temp = low + else: + temp = high + else: + if self.is_away_mode_on: + # away_temperature is a low, high tuple. Only one should be set + # if not in range mode, the other will be None + temp = self.device.away_temperature[0] or \ + self.device.away_temperature[1] + else: + temp = self.device.target + + return temp + + @property + def target_temperature_low(self): + """Return the lower bound temperature we try to reach.""" + if self.is_away_mode_on and self.device.away_temperature[0]: + # away_temperature is always a low, high tuple + return self.device.away_temperature[0] + if self.device.mode == 'range': + return self.device.target[0] + return self.target_temperature + + @property + def target_temperature_high(self): + """Return the upper bound temperature we try to reach.""" + if self.is_away_mode_on and self.device.away_temperature[1]: + # away_temperature is always a low, high tuple + return self.device.away_temperature[1] + if self.device.mode == 'range': + return self.device.target[1] + return self.target_temperature + + @property + def is_away_mode_on(self): + """Return if away mode is on.""" + return self.structure.away + + def set_temperature(self, temperature): + """Set new target temperature.""" + if self.device.mode == 'range': + if self.target_temperature == self.target_temperature_low: + temperature = (temperature, self.target_temperature_high) + elif self.target_temperature == self.target_temperature_high: + temperature = (self.target_temperature_low, temperature) + self.device.target = temperature + + def set_operation_mode(self, operation_mode): + """Set operation mode.""" + self.device.mode = operation_mode + + def turn_away_mode_on(self): + """Turn away on.""" + self.structure.away = True + + def turn_away_mode_off(self): + """Turn away off.""" + self.structure.away = False + + @property + def is_fan_on(self): + """Return whether the fan is on.""" + return self.device.fan + + def turn_fan_on(self): + """Turn fan on.""" + self.device.fan = True + + def turn_fan_off(self): + """Turn fan off.""" + self.device.fan = False + + @property + def min_temp(self): + """Identify min_temp in Nest API or defaults if not available.""" + temp = self.device.away_temperature.low + if temp is None: + return super().min_temp + else: + return temp + + @property + def max_temp(self): + """Identify max_temp in Nest API or defaults if not available.""" + temp = self.device.away_temperature.high + if temp is None: + return super().max_temp + else: + return temp + + def update(self): + """Python-nest has its own mechanism for staying up to date.""" + pass diff --git a/homeassistant/components/climate/proliphix.py b/homeassistant/components/climate/proliphix.py new file mode 100644 index 00000000000..c6e8ed69617 --- /dev/null +++ b/homeassistant/components/climate/proliphix.py @@ -0,0 +1,90 @@ +""" +Support for Proliphix NT10e Thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.proliphix/ +""" +from homeassistant.components.climate import ( + STATE_COOL, STATE_HEAT, STATE_IDLE, ClimateDevice) +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, TEMP_FAHRENHEIT) + +REQUIREMENTS = ['proliphix==0.3.1'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Proliphix thermostats.""" + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + host = config.get(CONF_HOST) + + import proliphix + + pdp = proliphix.PDP(host, username, password) + + add_devices([ + ProliphixThermostat(pdp) + ]) + + +# pylint: disable=abstract-method +class ProliphixThermostat(ClimateDevice): + """Representation a Proliphix thermostat.""" + + def __init__(self, pdp): + """Initialize the thermostat.""" + self._pdp = pdp + # initial data + self._pdp.update() + self._name = self._pdp.name + + @property + def should_poll(self): + """Polling needed for thermostat.""" + return True + + def update(self): + """Update the data from the thermostat.""" + self._pdp.update() + + @property + def name(self): + """Return the name of the thermostat.""" + return self._name + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + return { + "fan": self._pdp.fan_state + } + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return TEMP_FAHRENHEIT + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._pdp.cur_temp + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._pdp.setback + + @property + def current_operation(self): + """Return the current state of the thermostat.""" + state = self._pdp.hvac_state + if state in (1, 2): + return STATE_IDLE + elif state == 3: + return STATE_HEAT + elif state == 6: + return STATE_COOL + + def set_temperature(self, temperature): + """Set new target temperature.""" + self._pdp.setback = temperature diff --git a/homeassistant/components/climate/radiotherm.py b/homeassistant/components/climate/radiotherm.py new file mode 100644 index 00000000000..deee3d53f3f --- /dev/null +++ b/homeassistant/components/climate/radiotherm.py @@ -0,0 +1,136 @@ +""" +Support for Radio Thermostat wifi-enabled home thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.radiotherm/ +""" +import datetime +import logging +from urllib.error import URLError + +from homeassistant.components.climate import ( + STATE_AUTO, STATE_COOL, STATE_HEAT, STATE_IDLE, STATE_OFF, + ClimateDevice) +from homeassistant.const import CONF_HOST, TEMP_FAHRENHEIT + +REQUIREMENTS = ['radiotherm==1.2'] +HOLD_TEMP = 'hold_temp' +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Radio Thermostat.""" + import radiotherm + + hosts = [] + if CONF_HOST in config: + hosts = config[CONF_HOST] + else: + hosts.append(radiotherm.discover.discover_address()) + + if hosts is None: + _LOGGER.error("No radiotherm thermostats detected.") + return False + + hold_temp = config.get(HOLD_TEMP, False) + tstats = [] + + for host in hosts: + try: + tstat = radiotherm.get_thermostat(host) + tstats.append(RadioThermostat(tstat, hold_temp)) + except (URLError, OSError): + _LOGGER.exception("Unable to connect to Radio Thermostat: %s", + host) + + add_devices(tstats) + + +# pylint: disable=abstract-method +class RadioThermostat(ClimateDevice): + """Representation of a Radio Thermostat.""" + + def __init__(self, device, hold_temp): + """Initialize the thermostat.""" + self.device = device + self.set_time() + self._target_temperature = None + self._current_temperature = None + self._current_operation = STATE_IDLE + self._name = None + self.hold_temp = hold_temp + self.update() + + @property + def name(self): + """Return the name of the Radio Thermostat.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return TEMP_FAHRENHEIT + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + return { + "fan": self.device.fmode['human'], + "mode": self.device.tmode['human'] + } + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def current_operation(self): + """Return the current operation. head, cool idle.""" + return self._current_operation + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + def update(self): + """Update the data from the thermostat.""" + self._current_temperature = self.device.temp['raw'] + self._name = self.device.name['raw'] + if self.device.tmode['human'] == 'Cool': + self._target_temperature = self.device.t_cool['raw'] + self._current_operation = STATE_COOL + elif self.device.tmode['human'] == 'Heat': + self._target_temperature = self.device.t_heat['raw'] + self._current_operation = STATE_HEAT + else: + self._current_operation = STATE_IDLE + + def set_temperature(self, temperature): + """Set new target temperature.""" + if self._current_operation == STATE_COOL: + self.device.t_cool = temperature + elif self._current_operation == STATE_HEAT: + self.device.t_heat = temperature + if self.hold_temp: + self.device.hold = 1 + else: + self.device.hold = 0 + + def set_time(self): + """Set device time.""" + now = datetime.datetime.now() + self.device.time = {'day': now.weekday(), + 'hour': now.hour, 'minute': now.minute} + + def set_operation_mode(self, operation_mode): + """Set operation mode (auto, cool, heat, off).""" + if operation_mode == STATE_OFF: + self.device.tmode = 0 + elif operation_mode == STATE_AUTO: + self.device.tmode = 3 + elif operation_mode == STATE_COOL: + self.device.t_cool = self._target_temperature + elif operation_mode == STATE_HEAT: + self.device.t_heat = self._target_temperature diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml new file mode 100644 index 00000000000..3a037d2a48b --- /dev/null +++ b/homeassistant/components/climate/services.yaml @@ -0,0 +1,84 @@ +set_aux_heat: + description: Turn auxillary heater on/off for climate device + + fields: + entity_id: + description: Name(s) of entities to change + example: 'climate.kitchen' + + aux_heat: + description: New value of axillary heater + example: true + +set_away_mode: + description: Turn away mode on/off for climate device + + fields: + entity_id: + description: Name(s) of entities to change + example: 'climate.kitchen' + + away_mode: + description: New value of away mode + example: true + +set_temperature: + description: Set target temperature of climate device + + fields: + entity_id: + description: Name(s) of entities to change + example: 'climate.kitchen' + + temperature: + description: New target temperature for hvac + example: 25 + +set_humidity: + description: Set target humidity of climate device + + fields: + entity_id: + description: Name(s) of entities to change + example: 'climate.kitchen' + + humidity: + description: New target humidity for climate device + example: 60 + +set_fan_mode: + description: Set fan operation for climate device + + fields: + entity_id: + description: Name(s) of entities to change + example: 'climate.nest' + + fan: + description: New value of fan mode + example: On Low + +set_operation_mode: + description: Set operation mode for climate device + + fields: + entity_id: + description: Name(s) of entities to change + example: 'climet.nest' + + operation_mode: + description: New value of operation mode + example: Heat + + +set_swing_mode: + description: Set swing operation for climate device + + fields: + entity_id: + description: Name(s) of entities to change + example: '.nest' + + swing_mode: + description: New value of swing mode + example: 1 diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py new file mode 100755 index 00000000000..54ca57e2163 --- /dev/null +++ b/homeassistant/components/climate/zwave.py @@ -0,0 +1,253 @@ +""" +Support for ZWave climate devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.zwave/ +""" +# Because we do not compile openzwave on CI +# pylint: disable=import-error +import logging +from homeassistant.components.climate import DOMAIN +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.zwave import ( + ATTR_NODE_ID, ATTR_VALUE_ID, ZWaveDeviceEntity) +from homeassistant.components import zwave +from homeassistant.const import (TEMP_FAHRENHEIT, TEMP_CELSIUS) + +_LOGGER = logging.getLogger(__name__) + +CONF_NAME = 'name' +DEFAULT_NAME = 'ZWave Climate' + +REMOTEC = 0x5254 +REMOTEC_ZXT_120 = 0x8377 +REMOTEC_ZXT_120_THERMOSTAT = (REMOTEC, REMOTEC_ZXT_120) + +COMMAND_CLASS_SENSOR_MULTILEVEL = 0x31 +COMMAND_CLASS_THERMOSTAT_MODE = 0x40 +COMMAND_CLASS_THERMOSTAT_SETPOINT = 0x43 +COMMAND_CLASS_THERMOSTAT_FAN_MODE = 0x44 +COMMAND_CLASS_CONFIGURATION = 0x70 + +WORKAROUND_ZXT_120 = 'zxt_120' + +DEVICE_MAPPINGS = { + REMOTEC_ZXT_120_THERMOSTAT: WORKAROUND_ZXT_120 +} + +ZXT_120_SET_TEMP = { + 'Heat': 1, + 'Cool': 2, + 'Dry Air': 8, + 'Auto Changeover': 10 +} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the ZWave Climate devices.""" + if discovery_info is None or zwave.NETWORK is None: + _LOGGER.debug("No discovery_info=%s or no NETWORK=%s", + discovery_info, zwave.NETWORK) + return + + node = zwave.NETWORK.nodes[discovery_info[ATTR_NODE_ID]] + value = node.values[discovery_info[ATTR_VALUE_ID]] + value.set_change_verified(False) + add_devices([ZWaveClimate(value)]) + _LOGGER.debug("discovery_info=%s and zwave.NETWORK=%s", + discovery_info, zwave.NETWORK) + + +# pylint: disable=too-many-arguments, abstract-method +class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): + """Represents a ZWave Climate device.""" + + # pylint: disable=too-many-public-methods, too-many-instance-attributes + def __init__(self, value): + """Initialize the zwave climate device.""" + from openzwave.network import ZWaveNetwork + from pydispatch import dispatcher + ZWaveDeviceEntity.__init__(self, value, DOMAIN) + self._node = value.node + self._target_temperature = None + self._current_temperature = None + self._current_operation = None + self._operation_list = None + self._current_fan_mode = None + self._fan_list = None + self._current_swing_mode = None + self._swing_list = None + self._unit = None + self._index = None + self._zxt_120 = None + self.update_properties() + # register listener + dispatcher.connect( + self.value_changed, ZWaveNetwork.SIGNAL_VALUE_CHANGED) + # Make sure that we have values for the key before converting to int + if (value.node.manufacturer_id.strip() and + value.node.product_id.strip()): + specific_sensor_key = (int(value.node.manufacturer_id, 16), + int(value.node.product_id, 16)) + + if specific_sensor_key in DEVICE_MAPPINGS: + if DEVICE_MAPPINGS[specific_sensor_key] == WORKAROUND_ZXT_120: + _LOGGER.debug("Remotec ZXT-120 Zwave Thermostat" + " workaround") + self._zxt_120 = 1 + + def value_changed(self, value): + """Called when a value has changed on the network.""" + if self._value.value_id == value.value_id or \ + self._value.node == value.node: + self.update_properties() + self.update_ha_state() + _LOGGER.debug("Value changed on network %s", value) + + def update_properties(self): + """Callback on data change for the registered node/value pair.""" + # Set point + temps = [] + for value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_SETPOINT).values(): + temps.append(int(value.data)) + if value.index == self._index: + self._target_temperature = int(value.data) + self._target_temperature_high = max(temps) + self._target_temperature_low = min(temps) + # Operation Mode + for value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_MODE).values(): + self._current_operation = value.data + self._operation_list = list(value.data_items) + _LOGGER.debug("self._operation_list=%s", self._operation_list) + _LOGGER.debug("self._current_operation=%s", + self._current_operation) + # Current Temp + for value in self._node.get_values( + class_id=COMMAND_CLASS_SENSOR_MULTILEVEL).values(): + if value.label == 'Temperature': + self._current_temperature = int(value.data) + self._unit = value.units + # Fan Mode + for value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_FAN_MODE).values(): + self._current_fan_mode = value.data + self._fan_list = list(value.data_items) + _LOGGER.debug("self._fan_list=%s", self._fan_list) + _LOGGER.debug("self._current_fan_mode=%s", + self._current_fan_mode) + # Swing mode + if self._zxt_120 == 1: + for value in self._node.get_values( + class_id=COMMAND_CLASS_CONFIGURATION).values(): + if value.command_class == 112 and value.index == 33: + self._current_swing_mode = value.data + self._swing_list = list(value.data_items) + _LOGGER.debug("self._swing_list=%s", self._swing_list) + _LOGGER.debug("self._current_swing_mode=%s", + self._current_swing_mode) + + @property + def should_poll(self): + """No polling on ZWave.""" + return False + + @property + def current_fan_mode(self): + """Return the fan speed set.""" + return self._current_fan_mode + + @property + def fan_list(self): + """List of available fan modes.""" + return self._fan_list + + @property + def current_swing_mode(self): + """Return the swing mode set.""" + return self._current_swing_mode + + @property + def swing_list(self): + """List of available swing modes.""" + return self._swing_list + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + unit = self._unit + if unit == 'C': + return TEMP_CELSIUS + elif unit == 'F': + return TEMP_FAHRENHEIT + else: + _LOGGER.exception("unit_of_measurement=%s is not valid", + unit) + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def current_operation(self): + """Return the current operation mode.""" + return self._current_operation + + @property + def operation_list(self): + """List of available operation modes.""" + return self._operation_list + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + def set_temperature(self, temperature): + """Set new target temperature.""" + for value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_SETPOINT).values(): + if value.command_class != 67 and value.index != self._index: + continue + if self._zxt_120: + # ZXT-120 does not support get setpoint + self._target_temperature = temperature + if ZXT_120_SET_TEMP.get(self._current_operation) \ + != value.index: + continue + _LOGGER.debug("ZXT_120_SET_TEMP=%s and" + " self._current_operation=%s", + ZXT_120_SET_TEMP.get(self._current_operation), + self._current_operation) + # ZXT-120 responds only to whole int + value.data = int(round(temperature, 0)) + else: + value.data = int(temperature) + break + + def set_fan_mode(self, fan): + """Set new target fan mode.""" + for value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_FAN_MODE).values(): + if value.command_class == 68 and value.index == 0: + value.data = bytes(fan, 'utf-8') + break + + def set_operation_mode(self, operation_mode): + """Set new target operation mode.""" + for value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_MODE).values(): + if value.command_class == 64 and value.index == 0: + value.data = bytes(operation_mode, 'utf-8') + break + + def set_swing_mode(self, swing_mode): + """Set new target swing mode.""" + if self._zxt_120 == 1: + for value in self._node.get_values( + class_id=COMMAND_CLASS_CONFIGURATION).values(): + if value.command_class == 112 and value.index == 33: + value.data = bytes(swing_mode, 'utf-8') + break diff --git a/homeassistant/components/ecobee.py b/homeassistant/components/ecobee.py index 470449b02cb..d684b903d0c 100644 --- a/homeassistant/components/ecobee.py +++ b/homeassistant/components/ecobee.py @@ -69,7 +69,7 @@ def setup_ecobee(hass, network, config): hold_temp = config[DOMAIN].get(HOLD_TEMP, False) - discovery.load_platform(hass, 'thermostat', DOMAIN, + discovery.load_platform(hass, 'climate', DOMAIN, {'hold_temp': hold_temp}, config) discovery.load_platform(hass, 'sensor', DOMAIN, {}, config) discovery.load_platform(hass, 'binary_sensor', DOMAIN, {}, config) diff --git a/homeassistant/components/homematic.py b/homeassistant/components/homematic.py index 8568408f3ec..fe65929a346 100644 --- a/homeassistant/components/homematic.py +++ b/homeassistant/components/homematic.py @@ -28,7 +28,7 @@ DISCOVER_LIGHTS = 'homematic.light' DISCOVER_SENSORS = 'homematic.sensor' DISCOVER_BINARY_SENSORS = 'homematic.binary_sensor' DISCOVER_ROLLERSHUTTER = 'homematic.rollershutter' -DISCOVER_THERMOSTATS = 'homematic.thermostat' +DISCOVER_THERMOSTATS = 'homematic.climate' ATTR_DISCOVER_DEVICES = 'devices' ATTR_PARAM = 'param' @@ -214,7 +214,7 @@ def system_callback_handler(hass, config, src, *args): ('rollershutter', DISCOVER_ROLLERSHUTTER), ('binary_sensor', DISCOVER_BINARY_SENSORS), ('sensor', DISCOVER_SENSORS), - ('thermostat', DISCOVER_THERMOSTATS)): + ('climate', DISCOVER_THERMOSTATS)): # Get all devices of a specific type found_devices = _get_devices(discovery_type, key_dict) diff --git a/homeassistant/components/nest.py b/homeassistant/components/nest.py index afccc043223..005add4e634 100644 --- a/homeassistant/components/nest.py +++ b/homeassistant/components/nest.py @@ -2,7 +2,7 @@ Support for Nest thermostats and protect smoke alarms. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/thermostat.nest/ +https://home-assistant.io/components/climate.nest/ """ import logging import socket diff --git a/homeassistant/components/thermostat/zwave.py b/homeassistant/components/thermostat/zwave.py index d44e6b5f70a..6bed82284bb 100644 --- a/homeassistant/components/thermostat/zwave.py +++ b/homeassistant/components/thermostat/zwave.py @@ -57,6 +57,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): COMMAND_CLASS_THERMOSTAT_SETPOINT)): return + if value.command_class != COMMAND_CLASS_SENSOR_MULTILEVEL and \ + value.command_class != COMMAND_CLASS_THERMOSTAT_SETPOINT: + return + add_devices([ZWaveThermostat(value)]) _LOGGER.debug("discovery_info=%s and zwave.NETWORK=%s", discovery_info, zwave.NETWORK) diff --git a/homeassistant/components/zwave.py b/homeassistant/components/zwave.py index a60319188ce..3e0db87664e 100644 --- a/homeassistant/components/zwave.py +++ b/homeassistant/components/zwave.py @@ -151,18 +151,6 @@ DISCOVERY_COMPONENTS = [ [COMMAND_CLASS_SENSOR_BINARY], TYPE_BOOL, GENRE_USER), - ('thermostat', - [GENERIC_COMMAND_CLASS_THERMOSTAT], - [SPECIFIC_DEVICE_CLASS_WHATEVER], - [COMMAND_CLASS_THERMOSTAT_SETPOINT], - TYPE_WHATEVER, - GENRE_WHATEVER), - ('hvac', - [GENERIC_COMMAND_CLASS_THERMOSTAT], - [SPECIFIC_DEVICE_CLASS_WHATEVER], - [COMMAND_CLASS_THERMOSTAT_FAN_MODE], - TYPE_WHATEVER, - GENRE_WHATEVER), ('lock', [GENERIC_COMMAND_CLASS_ENTRY_CONTROL], [SPECIFIC_DEVICE_CLASS_ADVANCED_DOOR_LOCK, @@ -186,7 +174,13 @@ DISCOVERY_COMPONENTS = [ [COMMAND_CLASS_SWITCH_BINARY, COMMAND_CLASS_BARRIER_OPERATOR], TYPE_BOOL, - GENRE_USER) + GENRE_USER), + ('climate', + [GENERIC_COMMAND_CLASS_THERMOSTAT], + [SPECIFIC_DEVICE_CLASS_WHATEVER], + [COMMAND_CLASS_THERMOSTAT_SETPOINT], + TYPE_WHATEVER, + GENRE_WHATEVER), ] diff --git a/requirements_all.txt b/requirements_all.txt index 0a210e55b98..94d610cdee1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -40,6 +40,7 @@ blinkstick==1.1.7 # homeassistant.components.sensor.bitcoin blockchain==1.3.3 +# homeassistant.components.climate.eq3btsmart # homeassistant.components.thermostat.eq3btsmart # bluepy_devices==0.2.0 @@ -67,6 +68,7 @@ eliqonline==1.0.12 # homeassistant.components.enocean enocean==0.31 +# homeassistant.components.climate.honeywell # homeassistant.components.thermostat.honeywell evohomeclient==0.2.5 @@ -103,6 +105,7 @@ ha-ffmpeg==0.4 # homeassistant.components.mqtt.server hbmqtt==0.7.1 +# homeassistant.components.climate.heatmiser # homeassistant.components.thermostat.heatmiser heatmiserV3==0.9.1 @@ -254,6 +257,7 @@ plexapi==2.0.2 # homeassistant.components.sensor.serial_pm pmsensor==0.2 +# homeassistant.components.climate.proliphix # homeassistant.components.thermostat.proliphix proliphix==0.3.1 @@ -388,6 +392,7 @@ pyvera==0.2.15 # homeassistant.components.wemo pywemo==0.4.5 +# homeassistant.components.climate.radiotherm # homeassistant.components.thermostat.radiotherm radiotherm==1.2 @@ -418,6 +423,7 @@ sleekxmpp==1.3.1 # homeassistant.components.media_player.snapcast snapcast==1.2.1 +# homeassistant.components.climate.honeywell # homeassistant.components.thermostat.honeywell somecomfort==0.2.1 diff --git a/tests/components/climate/__init__.py b/tests/components/climate/__init__.py new file mode 100644 index 00000000000..441c917cab7 --- /dev/null +++ b/tests/components/climate/__init__.py @@ -0,0 +1 @@ +"""The tests for climate component.""" diff --git a/tests/components/climate/test_demo.py b/tests/components/climate/test_demo.py new file mode 100644 index 00000000000..4dab359688c --- /dev/null +++ b/tests/components/climate/test_demo.py @@ -0,0 +1,166 @@ +"""The tests for the demo climate component.""" +import unittest + +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, +) +from homeassistant.components import climate + +from tests.common import get_test_home_assistant + + +ENTITY_CLIMATE = 'climate.hvac' + + +class TestDemoClimate(unittest.TestCase): + """Test the demo climate hvac.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.units = METRIC_SYSTEM + self.assertTrue(climate.setup(self.hass, {'climate': { + '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 inititial parameters.""" + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual(21, state.attributes.get('temperature')) + self.assertEqual('on', state.attributes.get('away_mode')) + self.assertEqual(22, state.attributes.get('current_temperature')) + self.assertEqual("On High", state.attributes.get('fan_mode')) + self.assertEqual(67, state.attributes.get('humidity')) + self.assertEqual(54, state.attributes.get('current_humidity')) + self.assertEqual("Off", state.attributes.get('swing_mode')) + self.assertEqual("Cool", state.attributes.get('operation_mode')) + self.assertEqual('off', state.attributes.get('aux_heat')) + + def test_default_setup_params(self): + """Test the setup with default parameters.""" + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual(7, state.attributes.get('min_temp')) + self.assertEqual(35, state.attributes.get('max_temp')) + self.assertEqual(30, state.attributes.get('min_humidity')) + self.assertEqual(99, state.attributes.get('max_humidity')) + + def test_set_target_temp_bad_attr(self): + """Test setting the target temperature without required attribute.""" + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual(21, state.attributes.get('temperature')) + climate.set_temperature(self.hass, None, ENTITY_CLIMATE) + self.hass.pool.block_till_done() + self.assertEqual(21, state.attributes.get('temperature')) + + def test_set_target_temp(self): + """Test the setting of the target temperature.""" + climate.set_temperature(self.hass, 30, ENTITY_CLIMATE) + self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual(30.0, state.attributes.get('temperature')) + + def test_set_target_humidity_bad_attr(self): + """Test setting the target humidity without required attribute.""" + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual(67, state.attributes.get('humidity')) + climate.set_humidity(self.hass, None, ENTITY_CLIMATE) + self.hass.pool.block_till_done() + self.assertEqual(67, state.attributes.get('humidity')) + + def test_set_target_humidity(self): + """Test the setting of the target humidity.""" + climate.set_humidity(self.hass, 64, ENTITY_CLIMATE) + self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual(64.0, state.attributes.get('humidity')) + + def test_set_fan_mode_bad_attr(self): + """Test setting fan mode without required attribute.""" + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("On High", state.attributes.get('fan_mode')) + climate.set_fan_mode(self.hass, None, ENTITY_CLIMATE) + self.hass.pool.block_till_done() + self.assertEqual("On High", state.attributes.get('fan_mode')) + + def test_set_fan_mode(self): + """Test setting of new fan mode.""" + climate.set_fan_mode(self.hass, "On Low", ENTITY_CLIMATE) + self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("On Low", state.attributes.get('fan_mode')) + + def test_set_swing_mode_bad_attr(self): + """Test setting swing mode without required attribute.""" + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("Off", state.attributes.get('swing_mode')) + climate.set_swing_mode(self.hass, None, ENTITY_CLIMATE) + self.hass.pool.block_till_done() + self.assertEqual("Off", state.attributes.get('swing_mode')) + + def test_set_swing(self): + """Test setting of new swing mode.""" + climate.set_swing_mode(self.hass, "Auto", ENTITY_CLIMATE) + self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("Auto", state.attributes.get('swing_mode')) + + def test_set_operation_bad_attr(self): + """Test setting operation mode without required attribute.""" + self.assertEqual("Cool", self.hass.states.get(ENTITY_CLIMATE).state) + climate.set_operation_mode(self.hass, None, ENTITY_CLIMATE) + self.hass.pool.block_till_done() + self.assertEqual("Cool", self.hass.states.get(ENTITY_CLIMATE).state) + + def test_set_operation(self): + """Test setting of new operation mode.""" + climate.set_operation_mode(self.hass, "Heat", ENTITY_CLIMATE) + self.hass.pool.block_till_done() + self.assertEqual("Heat", self.hass.states.get(ENTITY_CLIMATE).state) + + def test_set_away_mode_bad_attr(self): + """Test setting the away mode without required attribute.""" + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('on', state.attributes.get('away_mode')) + climate.set_away_mode(self.hass, None, ENTITY_CLIMATE) + self.hass.pool.block_till_done() + self.assertEqual('on', state.attributes.get('away_mode')) + + def test_set_away_mode_on(self): + """Test setting the away mode on/true.""" + climate.set_away_mode(self.hass, True, ENTITY_CLIMATE) + self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('on', state.attributes.get('away_mode')) + + def test_set_away_mode_off(self): + """Test setting the away mode off/false.""" + climate.set_away_mode(self.hass, False, ENTITY_CLIMATE) + self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('off', state.attributes.get('away_mode')) + + def test_set_aux_heat_bad_attr(self): + """Test setting the auxillary heater without required attribute.""" + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('off', state.attributes.get('aux_heat')) + climate.set_aux_heat(self.hass, None, ENTITY_CLIMATE) + self.hass.pool.block_till_done() + self.assertEqual('off', state.attributes.get('aux_heat')) + + def test_set_aux_heat_on(self): + """Test setting the axillary heater on/true.""" + climate.set_aux_heat(self.hass, True, ENTITY_CLIMATE) + self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('on', state.attributes.get('aux_heat')) + + def test_set_aux_heat_off(self): + """Test setting the auxillary heater off/false.""" + climate.set_aux_heat(self.hass, False, ENTITY_CLIMATE) + self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('off', state.attributes.get('aux_heat')) diff --git a/tests/components/climate/test_generic_thermostat.py b/tests/components/climate/test_generic_thermostat.py new file mode 100644 index 00000000000..5c03abdf90f --- /dev/null +++ b/tests/components/climate/test_generic_thermostat.py @@ -0,0 +1,493 @@ +"""The tests for the generic_thermostat.""" +import datetime +import unittest +from unittest import mock + + +from homeassistant.bootstrap import _setup_component +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_ON, + STATE_OFF, + TEMP_CELSIUS, +) +from homeassistant.util.unit_system import METRIC_SYSTEM +from homeassistant.components import climate + +from tests.common import get_test_home_assistant + + +ENTITY = 'climate.test' +ENT_SENSOR = 'sensor.test' +ENT_SWITCH = 'switch.test' +MIN_TEMP = 3.0 +MAX_TEMP = 65.0 +TARGET_TEMP = 42.0 + + +class TestSetupClimateGenericThermostat(unittest.TestCase): + """Test the Generic thermostat with custom config.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_setup_missing_conf(self): + """Test set up heat_control with missing config values.""" + config = { + 'name': 'test', + 'target_sensor': ENT_SENSOR + } + self.assertFalse(_setup_component(self.hass, 'climate', { + 'climate': config})) + + def test_valid_conf(self): + """Test set up genreic_thermostat with valid config values.""" + self.assertTrue(_setup_component(self.hass, 'climate', + {'climate': { + 'platform': 'generic_thermostat', + 'name': 'test', + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR}})) + + def test_setup_with_sensor(self): + """Test set up heat_control with sensor to trigger update at init.""" + self.hass.states.set(ENT_SENSOR, 22.0, { + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS + }) + climate.setup(self.hass, {'climate': { + 'platform': 'generic_thermostat', + 'name': 'test', + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR + }}) + state = self.hass.states.get(ENTITY) + self.assertEqual( + TEMP_CELSIUS, state.attributes.get('unit_of_measurement')) + self.assertEqual(22.0, state.attributes.get('current_temperature')) + + +class TestClimateGenericThermostat(unittest.TestCase): + """Test the Generic thermostat.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.units = METRIC_SYSTEM + climate.setup(self.hass, {'climate': { + 'platform': 'generic_thermostat', + 'name': 'test', + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR + }}) + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_setup_defaults_to_unknown(self): + """Test the setting of defaults to unknown.""" + self.assertEqual('unknown', self.hass.states.get(ENTITY).state) + + def test_default_setup_params(self): + """Test the setup with default parameters.""" + state = self.hass.states.get(ENTITY) + self.assertEqual(7, state.attributes.get('min_temp')) + self.assertEqual(35, state.attributes.get('max_temp')) + self.assertEqual(None, state.attributes.get('temperature')) + + def test_custom_setup_params(self): + """Test the setup with custom parameters.""" + climate.setup(self.hass, {'climate': { + 'platform': 'generic_thermostat', + 'name': 'test', + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR, + 'min_temp': MIN_TEMP, + 'max_temp': MAX_TEMP, + 'target_temp': TARGET_TEMP + }}) + state = self.hass.states.get(ENTITY) + self.assertEqual(MIN_TEMP, state.attributes.get('min_temp')) + self.assertEqual(MAX_TEMP, state.attributes.get('max_temp')) + self.assertEqual(TARGET_TEMP, state.attributes.get('temperature')) + + def test_set_target_temp(self): + """Test the setting of the target temperature.""" + climate.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + state = self.hass.states.get(ENTITY) + self.assertEqual(30.0, state.attributes.get('temperature')) + + def test_sensor_bad_unit(self): + """Test sensor that have bad unit.""" + state = self.hass.states.get(ENTITY) + temp = state.attributes.get('current_temperature') + unit = state.attributes.get('unit_of_measurement') + + self._setup_sensor(22.0, unit='bad_unit') + self.hass.pool.block_till_done() + + state = self.hass.states.get(ENTITY) + self.assertEqual(unit, state.attributes.get('unit_of_measurement')) + self.assertEqual(temp, state.attributes.get('current_temperature')) + + def test_sensor_bad_value(self): + """Test sensor that have None as state.""" + state = self.hass.states.get(ENTITY) + temp = state.attributes.get('current_temperature') + unit = state.attributes.get('unit_of_measurement') + + self._setup_sensor(None) + self.hass.pool.block_till_done() + + state = self.hass.states.get(ENTITY) + self.assertEqual(unit, state.attributes.get('unit_of_measurement')) + self.assertEqual(temp, state.attributes.get('current_temperature')) + + def test_set_target_temp_heater_on(self): + """Test if target temperature turn heater on.""" + self._setup_switch(False) + self._setup_sensor(25) + self.hass.pool.block_till_done() + climate.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_ON, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def test_set_target_temp_heater_off(self): + """Test if target temperature turn heater off.""" + self._setup_switch(True) + self._setup_sensor(30) + self.hass.pool.block_till_done() + climate.set_temperature(self.hass, 25) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_OFF, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def test_set_temp_change_heater_on(self): + """Test if temperature change turn heater on.""" + self._setup_switch(False) + climate.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + self._setup_sensor(25) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_ON, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def test_temp_change_heater_off(self): + """Test if temperature change turn heater off.""" + self._setup_switch(True) + climate.set_temperature(self.hass, 25) + self.hass.pool.block_till_done() + self._setup_sensor(30) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_OFF, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def _setup_sensor(self, temp, unit=TEMP_CELSIUS): + """Setup the test sensor.""" + self.hass.states.set(ENT_SENSOR, temp, { + ATTR_UNIT_OF_MEASUREMENT: unit + }) + + def _setup_switch(self, is_on): + """Setup the test switch.""" + self.hass.states.set(ENT_SWITCH, STATE_ON if is_on else STATE_OFF) + self.calls = [] + + def log_call(call): + """Log service calls.""" + self.calls.append(call) + + self.hass.services.register('switch', SERVICE_TURN_ON, log_call) + self.hass.services.register('switch', SERVICE_TURN_OFF, log_call) + + +class TestClimateGenericThermostatACMode(unittest.TestCase): + """Test the Generic thermostat.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.temperature_unit = TEMP_CELSIUS + climate.setup(self.hass, {'climate': { + 'platform': 'generic_thermostat', + 'name': 'test', + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR, + 'ac_mode': True + }}) + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_set_target_temp_ac_off(self): + """Test if target temperature turn ac off.""" + self._setup_switch(True) + self._setup_sensor(25) + self.hass.pool.block_till_done() + climate.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_OFF, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def test_set_target_temp_ac_on(self): + """Test if target temperature turn ac on.""" + self._setup_switch(False) + self._setup_sensor(30) + self.hass.pool.block_till_done() + climate.set_temperature(self.hass, 25) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_ON, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def test_set_temp_change_ac_off(self): + """Test if temperature change turn ac off.""" + self._setup_switch(True) + climate.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + self._setup_sensor(25) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_OFF, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def test_temp_change_ac_on(self): + """Test if temperature change turn ac on.""" + self._setup_switch(False) + climate.set_temperature(self.hass, 25) + self.hass.pool.block_till_done() + self._setup_sensor(30) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_ON, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def _setup_sensor(self, temp, unit=TEMP_CELSIUS): + """Setup the test sensor.""" + self.hass.states.set(ENT_SENSOR, temp, { + ATTR_UNIT_OF_MEASUREMENT: unit + }) + + def _setup_switch(self, is_on): + """Setup the test switch.""" + self.hass.states.set(ENT_SWITCH, STATE_ON if is_on else STATE_OFF) + self.calls = [] + + def log_call(call): + """Log service calls.""" + self.calls.append(call) + + self.hass.services.register('switch', SERVICE_TURN_ON, log_call) + self.hass.services.register('switch', SERVICE_TURN_OFF, log_call) + + +class TestClimateGenericThermostatACModeMinCycle(unittest.TestCase): + """Test the Generic Thermostat.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.temperature_unit = TEMP_CELSIUS + climate.setup(self.hass, {'climate': { + 'platform': 'generic_thermostat', + 'name': 'test', + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR, + 'ac_mode': True, + 'min_cycle_duration': datetime.timedelta(minutes=10) + }}) + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_temp_change_ac_trigger_on_not_long_enough(self): + """Test if temperature change turn ac on.""" + self._setup_switch(False) + climate.set_temperature(self.hass, 25) + self.hass.pool.block_till_done() + self._setup_sensor(30) + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_temp_change_ac_trigger_on_long_enough(self): + """Test if temperature change turn ac on.""" + fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11, + tzinfo=datetime.timezone.utc) + with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=fake_changed): + self._setup_switch(False) + climate.set_temperature(self.hass, 25) + self.hass.pool.block_till_done() + self._setup_sensor(30) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_ON, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def test_temp_change_ac_trigger_off_not_long_enough(self): + """Test if temperature change turn ac on.""" + self._setup_switch(True) + climate.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + self._setup_sensor(25) + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_temp_change_ac_trigger_off_long_enough(self): + """Test if temperature change turn ac on.""" + fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11, + tzinfo=datetime.timezone.utc) + with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=fake_changed): + self._setup_switch(True) + climate.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + self._setup_sensor(25) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_OFF, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def _setup_sensor(self, temp, unit=TEMP_CELSIUS): + """Setup the test sensor.""" + self.hass.states.set(ENT_SENSOR, temp, { + ATTR_UNIT_OF_MEASUREMENT: unit + }) + + def _setup_switch(self, is_on): + """Setup the test switch.""" + self.hass.states.set(ENT_SWITCH, STATE_ON if is_on else STATE_OFF) + self.calls = [] + + def log_call(call): + """Log service calls.""" + self.calls.append(call) + + self.hass.services.register('switch', SERVICE_TURN_ON, log_call) + self.hass.services.register('switch', SERVICE_TURN_OFF, log_call) + + +class TestClimateGenericThermostatMinCycle(unittest.TestCase): + """Test the Generic thermostat.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.temperature_unit = TEMP_CELSIUS + climate.setup(self.hass, {'climate': { + 'platform': 'generic_thermostat', + 'name': 'test', + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR, + 'min_cycle_duration': datetime.timedelta(minutes=10) + }}) + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_temp_change_heater_trigger_off_not_long_enough(self): + """Test if temp change doesn't turn heater off because of time.""" + self._setup_switch(True) + climate.set_temperature(self.hass, 25) + self.hass.pool.block_till_done() + self._setup_sensor(30) + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_temp_change_heater_trigger_on_not_long_enough(self): + """Test if temp change doesn't turn heater on because of time.""" + self._setup_switch(False) + climate.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + self._setup_sensor(25) + self.hass.pool.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_temp_change_heater_trigger_on_long_enough(self): + """Test if temperature change turn heater on after min cycle.""" + fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11, + tzinfo=datetime.timezone.utc) + with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=fake_changed): + self._setup_switch(False) + climate.set_temperature(self.hass, 30) + self.hass.pool.block_till_done() + self._setup_sensor(25) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_ON, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def test_temp_change_heater_trigger_off_long_enough(self): + """Test if temperature change turn heater off after min cycle.""" + fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11, + tzinfo=datetime.timezone.utc) + with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=fake_changed): + self._setup_switch(True) + climate.set_temperature(self.hass, 25) + self.hass.pool.block_till_done() + self._setup_sensor(30) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + call = self.calls[0] + self.assertEqual('switch', call.domain) + self.assertEqual(SERVICE_TURN_OFF, call.service) + self.assertEqual(ENT_SWITCH, call.data['entity_id']) + + def _setup_sensor(self, temp, unit=TEMP_CELSIUS): + """Setup the test sensor.""" + self.hass.states.set(ENT_SENSOR, temp, { + ATTR_UNIT_OF_MEASUREMENT: unit + }) + + def _setup_switch(self, is_on): + """Setup the test switch.""" + self.hass.states.set(ENT_SWITCH, STATE_ON if is_on else STATE_OFF) + self.calls = [] + + def log_call(call): + """Log service calls.""" + self.calls.append(call) + + self.hass.services.register('switch', SERVICE_TURN_ON, log_call) + self.hass.services.register('switch', SERVICE_TURN_OFF, log_call) diff --git a/tests/components/climate/test_honeywell.py b/tests/components/climate/test_honeywell.py new file mode 100644 index 00000000000..6c97b65dea7 --- /dev/null +++ b/tests/components/climate/test_honeywell.py @@ -0,0 +1,377 @@ +"""The test the Honeywell thermostat module.""" +import socket +import unittest +from unittest import mock + +import somecomfort + +from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, + TEMP_CELSIUS, TEMP_FAHRENHEIT) +import homeassistant.components.climate.honeywell as honeywell + + +class TestHoneywell(unittest.TestCase): + """A test class for Honeywell themostats.""" + + @mock.patch('somecomfort.SomeComfort') + @mock.patch('homeassistant.components.climate.' + 'honeywell.HoneywellUSThermostat') + def test_setup_us(self, mock_ht, mock_sc): + """Test for the US setup.""" + config = { + CONF_USERNAME: 'user', + CONF_PASSWORD: 'pass', + 'region': 'us', + } + bad_pass_config = { + CONF_USERNAME: 'user', + 'region': 'us', + } + bad_region_config = { + CONF_USERNAME: 'user', + CONF_PASSWORD: 'pass', + 'region': 'un', + } + hass = mock.MagicMock() + add_devices = mock.MagicMock() + + locations = [ + mock.MagicMock(), + mock.MagicMock(), + ] + devices_1 = [mock.MagicMock()] + devices_2 = [mock.MagicMock(), mock.MagicMock] + mock_sc.return_value.locations_by_id.values.return_value = \ + locations + locations[0].devices_by_id.values.return_value = devices_1 + locations[1].devices_by_id.values.return_value = devices_2 + + result = honeywell.setup_platform(hass, bad_pass_config, add_devices) + self.assertFalse(result) + result = honeywell.setup_platform(hass, bad_region_config, add_devices) + self.assertFalse(result) + result = honeywell.setup_platform(hass, config, add_devices) + self.assertTrue(result) + mock_sc.assert_called_once_with('user', 'pass') + mock_ht.assert_has_calls([ + mock.call(mock_sc.return_value, devices_1[0]), + mock.call(mock_sc.return_value, devices_2[0]), + mock.call(mock_sc.return_value, devices_2[1]), + ]) + + @mock.patch('somecomfort.SomeComfort') + def test_setup_us_failures(self, mock_sc): + """Test the US setup.""" + hass = mock.MagicMock() + add_devices = mock.MagicMock() + config = { + CONF_USERNAME: 'user', + CONF_PASSWORD: 'pass', + 'region': 'us', + } + + mock_sc.side_effect = somecomfort.AuthError + result = honeywell.setup_platform(hass, config, add_devices) + self.assertFalse(result) + self.assertFalse(add_devices.called) + + mock_sc.side_effect = somecomfort.SomeComfortError + result = honeywell.setup_platform(hass, config, add_devices) + self.assertFalse(result) + self.assertFalse(add_devices.called) + + @mock.patch('somecomfort.SomeComfort') + @mock.patch('homeassistant.components.climate.' + 'honeywell.HoneywellUSThermostat') + def _test_us_filtered_devices(self, mock_ht, mock_sc, loc=None, dev=None): + """Test for US filtered thermostats.""" + config = { + CONF_USERNAME: 'user', + CONF_PASSWORD: 'pass', + 'region': 'us', + 'location': loc, + 'thermostat': dev, + } + locations = { + 1: mock.MagicMock(locationid=mock.sentinel.loc1, + devices_by_id={ + 11: mock.MagicMock( + deviceid=mock.sentinel.loc1dev1), + 12: mock.MagicMock( + deviceid=mock.sentinel.loc1dev2), + }), + 2: mock.MagicMock(locationid=mock.sentinel.loc2, + devices_by_id={ + 21: mock.MagicMock( + deviceid=mock.sentinel.loc2dev1), + }), + 3: mock.MagicMock(locationid=mock.sentinel.loc3, + devices_by_id={ + 31: mock.MagicMock( + deviceid=mock.sentinel.loc3dev1), + }), + } + mock_sc.return_value = mock.MagicMock(locations_by_id=locations) + hass = mock.MagicMock() + add_devices = mock.MagicMock() + self.assertEqual(True, + honeywell.setup_platform(hass, config, add_devices)) + + return mock_ht.call_args_list, mock_sc + + def test_us_filtered_thermostat_1(self): + """Test for US filtered thermostats.""" + result, client = self._test_us_filtered_devices( + dev=mock.sentinel.loc1dev1) + devices = [x[0][1].deviceid for x in result] + self.assertEqual([mock.sentinel.loc1dev1], devices) + + def test_us_filtered_thermostat_2(self): + """Test for US filtered location.""" + result, client = self._test_us_filtered_devices( + dev=mock.sentinel.loc2dev1) + devices = [x[0][1].deviceid for x in result] + self.assertEqual([mock.sentinel.loc2dev1], devices) + + def test_us_filtered_location_1(self): + """Test for US filtered locations.""" + result, client = self._test_us_filtered_devices( + loc=mock.sentinel.loc1) + devices = [x[0][1].deviceid for x in result] + self.assertEqual([mock.sentinel.loc1dev1, + mock.sentinel.loc1dev2], devices) + + def test_us_filtered_location_2(self): + """Test for US filtered locations.""" + result, client = self._test_us_filtered_devices( + loc=mock.sentinel.loc2) + devices = [x[0][1].deviceid for x in result] + self.assertEqual([mock.sentinel.loc2dev1], devices) + + @mock.patch('evohomeclient.EvohomeClient') + @mock.patch('homeassistant.components.climate.honeywell.' + 'RoundThermostat') + def test_eu_setup_full_config(self, mock_round, mock_evo): + """Test the EU setup wwith complete configuration.""" + config = { + CONF_USERNAME: 'user', + CONF_PASSWORD: 'pass', + honeywell.CONF_AWAY_TEMP: 20, + 'region': 'eu', + } + mock_evo.return_value.temperatures.return_value = [ + {'id': 'foo'}, {'id': 'bar'}] + hass = mock.MagicMock() + add_devices = mock.MagicMock() + self.assertTrue(honeywell.setup_platform(hass, config, add_devices)) + mock_evo.assert_called_once_with('user', 'pass') + mock_evo.return_value.temperatures.assert_called_once_with( + force_refresh=True) + mock_round.assert_has_calls([ + mock.call(mock_evo.return_value, 'foo', True, 20), + mock.call(mock_evo.return_value, 'bar', False, 20), + ]) + self.assertEqual(2, add_devices.call_count) + + @mock.patch('evohomeclient.EvohomeClient') + @mock.patch('homeassistant.components.climate.honeywell.' + 'RoundThermostat') + def test_eu_setup_partial_config(self, mock_round, mock_evo): + """Test the EU setup with partial configuration.""" + config = { + CONF_USERNAME: 'user', + CONF_PASSWORD: 'pass', + 'region': 'eu', + } + mock_evo.return_value.temperatures.return_value = [ + {'id': 'foo'}, {'id': 'bar'}] + hass = mock.MagicMock() + add_devices = mock.MagicMock() + self.assertTrue(honeywell.setup_platform(hass, config, add_devices)) + default = honeywell.DEFAULT_AWAY_TEMP + mock_round.assert_has_calls([ + mock.call(mock_evo.return_value, 'foo', True, default), + mock.call(mock_evo.return_value, 'bar', False, default), + ]) + + @mock.patch('evohomeclient.EvohomeClient') + @mock.patch('homeassistant.components.climate.honeywell.' + 'RoundThermostat') + def test_eu_setup_bad_temp(self, mock_round, mock_evo): + """Test the EU setup with invalid temperature.""" + config = { + CONF_USERNAME: 'user', + CONF_PASSWORD: 'pass', + honeywell.CONF_AWAY_TEMP: 'ponies', + 'region': 'eu', + } + self.assertFalse(honeywell.setup_platform(None, config, None)) + + @mock.patch('evohomeclient.EvohomeClient') + @mock.patch('homeassistant.components.climate.honeywell.' + 'RoundThermostat') + def test_eu_setup_error(self, mock_round, mock_evo): + """Test the EU setup with errors.""" + config = { + CONF_USERNAME: 'user', + CONF_PASSWORD: 'pass', + honeywell.CONF_AWAY_TEMP: 20, + 'region': 'eu', + } + mock_evo.return_value.temperatures.side_effect = socket.error + add_devices = mock.MagicMock() + hass = mock.MagicMock() + self.assertFalse(honeywell.setup_platform(hass, config, add_devices)) + + +class TestHoneywellRound(unittest.TestCase): + """A test class for Honeywell Round thermostats.""" + + def setup_method(self, method): + """Test the setup method.""" + def fake_temperatures(force_refresh=None): + """Create fake temperatures.""" + temps = [ + {'id': '1', 'temp': 20, 'setpoint': 21, + 'thermostat': 'main', 'name': 'House'}, + {'id': '2', 'temp': 21, 'setpoint': 22, + 'thermostat': 'DOMESTIC_HOT_WATER'}, + ] + return temps + + self.device = mock.MagicMock() + self.device.temperatures.side_effect = fake_temperatures + self.round1 = honeywell.RoundThermostat(self.device, '1', + True, 16) + self.round2 = honeywell.RoundThermostat(self.device, '2', + False, 17) + + def test_attributes(self): + """Test the attributes.""" + self.assertEqual('House', self.round1.name) + self.assertEqual(TEMP_CELSIUS, self.round1.unit_of_measurement) + self.assertEqual(20, self.round1.current_temperature) + self.assertEqual(21, self.round1.target_temperature) + self.assertFalse(self.round1.is_away_mode_on) + + self.assertEqual('Hot Water', self.round2.name) + self.assertEqual(TEMP_CELSIUS, self.round2.unit_of_measurement) + self.assertEqual(21, self.round2.current_temperature) + self.assertEqual(None, self.round2.target_temperature) + self.assertFalse(self.round2.is_away_mode_on) + + def test_away_mode(self): + """Test setting the away mode.""" + self.assertFalse(self.round1.is_away_mode_on) + self.round1.turn_away_mode_on() + self.assertTrue(self.round1.is_away_mode_on) + self.device.set_temperature.assert_called_once_with('House', 16) + + self.device.set_temperature.reset_mock() + self.round1.turn_away_mode_off() + self.assertFalse(self.round1.is_away_mode_on) + self.device.cancel_temp_override.assert_called_once_with('House') + + def test_set_temperature(self): + """Test setting the temperature.""" + self.round1.set_temperature(25) + self.device.set_temperature.assert_called_once_with('House', 25) + + def test_set_operation_mode(self: unittest.TestCase) -> None: + """Test setting the system operation.""" + self.round1.set_operation_mode('cool') + self.assertEqual('cool', self.round1.current_operation) + self.assertEqual('cool', self.device.system_mode) + + self.round1.set_operation_mode('heat') + self.assertEqual('heat', self.round1.current_operation) + self.assertEqual('heat', self.device.system_mode) + + +class TestHoneywellUS(unittest.TestCase): + """A test class for Honeywell US thermostats.""" + + def setup_method(self, method): + """Test the setup method.""" + self.client = mock.MagicMock() + self.device = mock.MagicMock() + self.honeywell = honeywell.HoneywellUSThermostat( + self.client, self.device) + + self.device.fan_running = True + self.device.name = 'test' + self.device.temperature_unit = 'F' + self.device.current_temperature = 72 + self.device.setpoint_cool = 78 + self.device.setpoint_heat = 65 + self.device.system_mode = 'heat' + self.device.fan_mode = 'auto' + + def test_properties(self): + """Test the properties.""" + self.assertTrue(self.honeywell.is_fan_on) + self.assertEqual('test', self.honeywell.name) + self.assertEqual(72, self.honeywell.current_temperature) + + def test_unit_of_measurement(self): + """Test the unit of measurement.""" + self.assertEqual(TEMP_FAHRENHEIT, self.honeywell.unit_of_measurement) + self.device.temperature_unit = 'C' + self.assertEqual(TEMP_CELSIUS, self.honeywell.unit_of_measurement) + + def test_target_temp(self): + """Test the target temperature.""" + self.assertEqual(65, self.honeywell.target_temperature) + self.device.system_mode = 'cool' + self.assertEqual(78, self.honeywell.target_temperature) + + def test_set_temp(self): + """Test setting the temperature.""" + self.honeywell.set_temperature(70) + self.assertEqual(70, self.device.setpoint_heat) + self.assertEqual(70, self.honeywell.target_temperature) + + self.device.system_mode = 'cool' + self.assertEqual(78, self.honeywell.target_temperature) + self.honeywell.set_temperature(74) + self.assertEqual(74, self.device.setpoint_cool) + self.assertEqual(74, self.honeywell.target_temperature) + + def test_set_operation_mode(self: unittest.TestCase) -> None: + """Test setting the operation mode.""" + self.honeywell.set_operation_mode('cool') + self.assertEqual('cool', self.honeywell.current_operation) + self.assertEqual('cool', self.device.system_mode) + + self.honeywell.set_operation_mode('heat') + self.assertEqual('heat', self.honeywell.current_operation) + self.assertEqual('heat', self.device.system_mode) + + def test_set_temp_fail(self): + """Test if setting the temperature fails.""" + self.device.setpoint_heat = mock.MagicMock( + side_effect=somecomfort.SomeComfortError) + self.honeywell.set_temperature(123) + + def test_attributes(self): + """Test the attributes.""" + expected = { + 'fan': 'running', + 'fanmode': 'auto', + 'system_mode': 'heat', + } + self.assertEqual(expected, self.honeywell.device_state_attributes) + expected['fan'] = 'idle' + self.device.fan_running = False + self.assertEqual(expected, self.honeywell.device_state_attributes) + + def test_with_no_fan(self): + """Test if there is on fan.""" + self.device.fan_running = False + self.device.fan_mode = None + expected = { + 'fan': 'idle', + 'fanmode': None, + 'system_mode': 'heat', + } + self.assertEqual(expected, self.honeywell.device_state_attributes) -- GitLab