From 6cafc9aaef4836ce27f4fa9bcb9097f6533243a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Mar 2020 13:45:33 -0500 Subject: [PATCH] Add humidity support to homekit thermostats (#33367) --- homeassistant/components/homekit/const.py | 1 + .../components/homekit/type_thermostats.py | 54 +++++++++++++++++++ .../homekit/test_type_thermostats.py | 48 +++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index ac421913f6f..c0f0abe8177 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -142,6 +142,7 @@ CHAR_SWING_MODE = "SwingMode" CHAR_TARGET_DOOR_STATE = "TargetDoorState" CHAR_TARGET_HEATING_COOLING = "TargetHeatingCoolingState" CHAR_TARGET_POSITION = "TargetPosition" +CHAR_TARGET_HUMIDITY = "TargetRelativeHumidity" CHAR_TARGET_SECURITY_STATE = "SecuritySystemTargetState" CHAR_TARGET_TEMPERATURE = "TargetTemperature" CHAR_TARGET_TILT_ANGLE = "TargetHorizontalTiltAngle" diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 19f7899d79b..b8c3b3f0197 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -4,11 +4,14 @@ import logging from pyhap.const import CATEGORY_THERMOSTAT from homeassistant.components.climate.const import ( + ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, + ATTR_HUMIDITY, ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_HVAC_MODES, ATTR_MAX_TEMP, + ATTR_MIN_HUMIDITY, ATTR_MIN_TEMP, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, @@ -17,6 +20,7 @@ from homeassistant.components.climate.const import ( CURRENT_HVAC_IDLE, CURRENT_HVAC_OFF, DEFAULT_MAX_TEMP, + DEFAULT_MIN_HUMIDITY, DEFAULT_MIN_TEMP, DOMAIN as DOMAIN_CLIMATE, HVAC_MODE_AUTO, @@ -25,8 +29,10 @@ from homeassistant.components.climate.const import ( HVAC_MODE_HEAT, HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF, + SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE as SERVICE_SET_HVAC_MODE_THERMOSTAT, SERVICE_SET_TEMPERATURE as SERVICE_SET_TEMPERATURE_THERMOSTAT, + SUPPORT_TARGET_HUMIDITY, SUPPORT_TARGET_TEMPERATURE_RANGE, ) from homeassistant.components.water_heater import ( @@ -39,6 +45,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, ) from . import TYPES @@ -46,9 +53,11 @@ from .accessories import HomeAccessory, debounce from .const import ( CHAR_COOLING_THRESHOLD_TEMPERATURE, CHAR_CURRENT_HEATING_COOLING, + CHAR_CURRENT_HUMIDITY, CHAR_CURRENT_TEMPERATURE, CHAR_HEATING_THRESHOLD_TEMPERATURE, CHAR_TARGET_HEATING_COOLING, + CHAR_TARGET_HUMIDITY, CHAR_TARGET_TEMPERATURE, CHAR_TEMP_DISPLAY_UNITS, DEFAULT_MAX_TEMP_WATER_HEATER, @@ -99,6 +108,10 @@ class Thermostat(HomeAccessory): self._flag_heatingthresh = False min_temp, max_temp = self.get_temperature_range() + min_humidity = self.hass.states.get(self.entity_id).attributes.get( + ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY + ) + # Add additional characteristics if auto mode is supported self.chars = [] state = self.hass.states.get(self.entity_id) @@ -109,6 +122,9 @@ class Thermostat(HomeAccessory): (CHAR_COOLING_THRESHOLD_TEMPERATURE, CHAR_HEATING_THRESHOLD_TEMPERATURE) ) + if features & SUPPORT_TARGET_HUMIDITY: + self.chars.extend((CHAR_TARGET_HUMIDITY, CHAR_CURRENT_HUMIDITY)) + serv_thermostat = self.add_preload_service(SERV_THERMOSTAT, self.chars) # Current mode characteristics @@ -193,6 +209,23 @@ class Thermostat(HomeAccessory): properties={PROP_MIN_VALUE: min_temp, PROP_MAX_VALUE: max_temp}, setter_callback=self.set_heating_threshold, ) + self.char_target_humidity = None + self.char_current_humidity = None + if CHAR_TARGET_HUMIDITY in self.chars: + self.char_target_humidity = serv_thermostat.configure_char( + CHAR_TARGET_HUMIDITY, + value=50, + # We do not set a max humidity because + # homekit currently has a bug that will show the lower bound + # shifted upwards. For example if you have a max humidity + # of 80% homekit will give you the options 20%-100% instead + # of 0-80% + properties={PROP_MIN_VALUE: min_humidity}, + setter_callback=self.set_target_humidity, + ) + self.char_current_humidity = serv_thermostat.configure_char( + CHAR_CURRENT_HUMIDITY, value=50, + ) def get_temperature_range(self): """Return min and max temperature range.""" @@ -224,6 +257,15 @@ class Thermostat(HomeAccessory): DOMAIN_CLIMATE, SERVICE_SET_HVAC_MODE_THERMOSTAT, params, hass_value ) + @debounce + def set_target_humidity(self, value): + """Set target humidity to value if call came from HomeKit.""" + _LOGGER.debug("%s: Set target humidity to %d", self.entity_id, value) + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_HUMIDITY: value} + self.call_service( + DOMAIN_CLIMATE, SERVICE_SET_HUMIDITY, params, f"{value}{UNIT_PERCENTAGE}", + ) + @debounce def set_cooling_threshold(self, value): """Set cooling threshold temp to value if call came from HomeKit.""" @@ -288,6 +330,12 @@ class Thermostat(HomeAccessory): current_temp = temperature_to_homekit(current_temp, self._unit) self.char_current_temp.set_value(current_temp) + # Update current humidity + if CHAR_CURRENT_HUMIDITY in self.chars: + current_humdity = new_state.attributes.get(ATTR_CURRENT_HUMIDITY) + if isinstance(current_humdity, (int, float)): + self.char_current_humidity.set_value(current_humdity) + # Update target temperature target_temp = new_state.attributes.get(ATTR_TEMPERATURE) if isinstance(target_temp, (int, float)): @@ -296,6 +344,12 @@ class Thermostat(HomeAccessory): self.char_target_temp.set_value(target_temp) self._flag_temperature = False + # Update target humidity + if CHAR_TARGET_HUMIDITY in self.chars: + target_humdity = new_state.attributes.get(ATTR_HUMIDITY) + if isinstance(target_humdity, (int, float)): + self.char_target_humidity.set_value(target_humdity) + # Update cooling threshold temperature if characteristic exists if self.char_cooling_thresh_temp: cooling_thresh = new_state.attributes.get(ATTR_TARGET_TEMP_HIGH) diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index df9a10fc409..756b20456fe 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -5,7 +5,9 @@ from unittest.mock import patch import pytest from homeassistant.components.climate.const import ( + ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, + ATTR_HUMIDITY, ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_HVAC_MODES, @@ -18,6 +20,7 @@ from homeassistant.components.climate.const import ( CURRENT_HVAC_HEAT, CURRENT_HVAC_IDLE, DEFAULT_MAX_TEMP, + DEFAULT_MIN_HUMIDITY, DEFAULT_MIN_TEMP, DOMAIN as DOMAIN_CLIMATE, HVAC_MODE_AUTO, @@ -99,6 +102,8 @@ async def test_thermostat(hass, hk_driver, cls, events): assert acc.char_display_units.value == 0 assert acc.char_cooling_thresh_temp is None assert acc.char_heating_thresh_temp is None + assert acc.char_target_humidity is None + assert acc.char_current_humidity is None assert acc.char_target_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP assert acc.char_target_temp.properties[PROP_MIN_VALUE] == DEFAULT_MIN_TEMP @@ -357,6 +362,49 @@ async def test_thermostat_auto(hass, hk_driver, cls, events): assert events[-1].data[ATTR_VALUE] == "cooling threshold 25.0°C" +async def test_thermostat_humidity(hass, hk_driver, cls, events): + """Test if accessory and HA are updated accordingly with humidity.""" + entity_id = "climate.test" + + # support_auto = True + hass.states.async_set(entity_id, HVAC_MODE_OFF, {ATTR_SUPPORTED_FEATURES: 4}) + await hass.async_block_till_done() + acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 2, None) + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + + assert acc.char_target_humidity.value == 50 + assert acc.char_current_humidity.value == 50 + + assert acc.char_target_humidity.properties[PROP_MIN_VALUE] == DEFAULT_MIN_HUMIDITY + + hass.states.async_set( + entity_id, HVAC_MODE_HEAT_COOL, {ATTR_HUMIDITY: 65, ATTR_CURRENT_HUMIDITY: 40}, + ) + await hass.async_block_till_done() + assert acc.char_current_humidity.value == 40 + assert acc.char_target_humidity.value == 65 + + hass.states.async_set( + entity_id, HVAC_MODE_COOL, {ATTR_HUMIDITY: 35, ATTR_CURRENT_HUMIDITY: 70}, + ) + await hass.async_block_till_done() + assert acc.char_current_humidity.value == 70 + assert acc.char_target_humidity.value == 35 + + # Set from HomeKit + call_set_humidity = async_mock_service(hass, DOMAIN_CLIMATE, "set_humidity") + + await hass.async_add_job(acc.char_target_humidity.client_update_value, 35) + await hass.async_block_till_done() + assert call_set_humidity[0] + assert call_set_humidity[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_humidity[0].data[ATTR_HUMIDITY] == 35 + assert acc.char_target_humidity.value == 35 + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] == "35%" + + async def test_thermostat_power_state(hass, hk_driver, cls, events): """Test if accessory and HA are updated accordingly.""" entity_id = "climate.test"