From 8b01f681ab3106708aed9f4b5fe64c23c6b4d184 Mon Sep 17 00:00:00 2001 From: Jacob Southard Date: Sun, 6 Dec 2020 17:23:08 -0600 Subject: [PATCH] Add target temperature range to homekit_controller (#42817) * Add support for temperature range in thermostat. * Add tests for setting temperature range. * Update Lennox E30/Ecobee 3 tests to reflect new supported feature * Add support for thermostate mode specific min/max temp values. --- .../components/homekit_controller/climate.py | 78 ++++++++-- .../specific_devices/test_ecobee3.py | 5 +- .../specific_devices/test_lennox_e30.py | 7 +- .../homekit_controller/test_climate.py | 136 ++++++++++++++++++ 4 files changed, 213 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index ed2d3c74d7d..042bc4771c1 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -19,6 +19,8 @@ from homeassistant.components.climate import ( ClimateEntity, ) from homeassistant.components.climate.const import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, CURRENT_HVAC_COOL, CURRENT_HVAC_HEAT, CURRENT_HVAC_IDLE, @@ -30,6 +32,7 @@ from homeassistant.components.climate.const import ( SUPPORT_SWING_MODE, SUPPORT_TARGET_HUMIDITY, SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_RANGE, SWING_OFF, SWING_VERTICAL, ) @@ -329,7 +332,9 @@ class HomeKitClimateEntity(HomeKitEntity, ClimateEntity): return [ CharacteristicsTypes.HEATING_COOLING_CURRENT, CharacteristicsTypes.HEATING_COOLING_TARGET, + CharacteristicsTypes.TEMPERATURE_COOLING_THRESHOLD, CharacteristicsTypes.TEMPERATURE_CURRENT, + CharacteristicsTypes.TEMPERATURE_HEATING_THRESHOLD, CharacteristicsTypes.TEMPERATURE_TARGET, CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT, CharacteristicsTypes.RELATIVE_HUMIDITY_TARGET, @@ -338,10 +343,23 @@ class HomeKitClimateEntity(HomeKitEntity, ClimateEntity): async def async_set_temperature(self, **kwargs): """Set new target temperature.""" temp = kwargs.get(ATTR_TEMPERATURE) - - await self.async_put_characteristics( - {CharacteristicsTypes.TEMPERATURE_TARGET: temp} - ) + heat_temp = kwargs.get(ATTR_TARGET_TEMP_LOW) + cool_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH) + value = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET) + if MODE_HOMEKIT_TO_HASS.get(value) in {HVAC_MODE_HEAT_COOL}: + if temp is None: + temp = (cool_temp + heat_temp) / 2 + await self.async_put_characteristics( + { + CharacteristicsTypes.TEMPERATURE_HEATING_THRESHOLD: heat_temp, + CharacteristicsTypes.TEMPERATURE_COOLING_THRESHOLD: cool_temp, + CharacteristicsTypes.TEMPERATURE_TARGET: temp, + } + ) + else: + await self.async_put_characteristics( + {CharacteristicsTypes.TEMPERATURE_TARGET: temp} + ) async def async_set_humidity(self, humidity): """Set new target humidity.""" @@ -367,22 +385,57 @@ class HomeKitClimateEntity(HomeKitEntity, ClimateEntity): @property def target_temperature(self): """Return the temperature we try to reach.""" + value = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET) + if MODE_HOMEKIT_TO_HASS.get(value) not in {HVAC_MODE_HEAT, HVAC_MODE_COOL}: + return None return self.service.value(CharacteristicsTypes.TEMPERATURE_TARGET) + @property + def target_temperature_high(self): + """Return the highbound target temperature we try to reach.""" + value = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET) + if MODE_HOMEKIT_TO_HASS.get(value) not in {HVAC_MODE_HEAT_COOL}: + return None + return self.service.value(CharacteristicsTypes.TEMPERATURE_COOLING_THRESHOLD) + + @property + def target_temperature_low(self): + """Return the lowbound target temperature we try to reach.""" + value = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET) + if MODE_HOMEKIT_TO_HASS.get(value) not in {HVAC_MODE_HEAT_COOL}: + return None + return self.service.value(CharacteristicsTypes.TEMPERATURE_HEATING_THRESHOLD) + @property def min_temp(self): """Return the minimum target temp.""" - if self.service.has(CharacteristicsTypes.TEMPERATURE_TARGET): - char = self.service[CharacteristicsTypes.TEMPERATURE_TARGET] - return char.minValue + value = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET) + if MODE_HOMEKIT_TO_HASS.get(value) in {HVAC_MODE_HEAT_COOL}: + min_temp = self.service[ + CharacteristicsTypes.TEMPERATURE_HEATING_THRESHOLD + ].minValue + if min_temp is not None: + return min_temp + if MODE_HOMEKIT_TO_HASS.get(value) in {HVAC_MODE_HEAT, HVAC_MODE_COOL}: + min_temp = self.service[CharacteristicsTypes.TEMPERATURE_TARGET].minValue + if min_temp is not None: + return min_temp return super().min_temp @property def max_temp(self): """Return the maximum target temp.""" - if self.service.has(CharacteristicsTypes.TEMPERATURE_TARGET): - char = self.service[CharacteristicsTypes.TEMPERATURE_TARGET] - return char.maxValue + value = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET) + if MODE_HOMEKIT_TO_HASS.get(value) in {HVAC_MODE_HEAT_COOL}: + max_temp = self.service[ + CharacteristicsTypes.TEMPERATURE_COOLING_THRESHOLD + ].maxValue + if max_temp is not None: + return max_temp + if MODE_HOMEKIT_TO_HASS.get(value) in {HVAC_MODE_HEAT, HVAC_MODE_COOL}: + max_temp = self.service[CharacteristicsTypes.TEMPERATURE_TARGET].maxValue + if max_temp is not None: + return max_temp return super().max_temp @property @@ -443,6 +496,11 @@ class HomeKitClimateEntity(HomeKitEntity, ClimateEntity): if self.service.has(CharacteristicsTypes.TEMPERATURE_TARGET): features |= SUPPORT_TARGET_TEMPERATURE + if self.service.has( + CharacteristicsTypes.TEMPERATURE_COOLING_THRESHOLD + ) and self.service.has(CharacteristicsTypes.TEMPERATURE_HEATING_THRESHOLD): + features |= SUPPORT_TARGET_TEMPERATURE_RANGE + if self.service.has(CharacteristicsTypes.RELATIVE_HUMIDITY_TARGET): features |= SUPPORT_TARGET_HUMIDITY diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index c1a956f3f4d..d05e36ed0eb 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -12,6 +12,7 @@ from aiohomekit.testing import FakePairing from homeassistant.components.climate.const import ( SUPPORT_TARGET_HUMIDITY, SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_RANGE, ) from homeassistant.config_entries import ENTRY_STATE_SETUP_RETRY @@ -40,7 +41,9 @@ async def test_ecobee3_setup(hass): climate_state = await climate_helper.poll_and_get_state() assert climate_state.attributes["friendly_name"] == "HomeW" assert climate_state.attributes["supported_features"] == ( - SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_HUMIDITY + SUPPORT_TARGET_TEMPERATURE + | SUPPORT_TARGET_TEMPERATURE_RANGE + | SUPPORT_TARGET_HUMIDITY ) assert climate_state.attributes["hvac_modes"] == [ diff --git a/tests/components/homekit_controller/specific_devices/test_lennox_e30.py b/tests/components/homekit_controller/specific_devices/test_lennox_e30.py index fe7b0c7783f..a49effdb75d 100644 --- a/tests/components/homekit_controller/specific_devices/test_lennox_e30.py +++ b/tests/components/homekit_controller/specific_devices/test_lennox_e30.py @@ -4,7 +4,10 @@ Regression tests for Aqara Gateway V3. https://github.com/home-assistant/core/issues/20885 """ -from homeassistant.components.climate.const import SUPPORT_TARGET_TEMPERATURE +from homeassistant.components.climate.const import ( + SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_RANGE, +) from tests.components.homekit_controller.common import ( Helper, @@ -29,7 +32,7 @@ async def test_lennox_e30_setup(hass): climate_state = await climate_helper.poll_and_get_state() assert climate_state.attributes["friendly_name"] == "Lennox" assert climate_state.attributes["supported_features"] == ( - SUPPORT_TARGET_TEMPERATURE + SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE_RANGE ) device_registry = await hass.helpers.device_registry.async_get_registry() diff --git a/tests/components/homekit_controller/test_climate.py b/tests/components/homekit_controller/test_climate.py index 38156354cda..d3f852d7a49 100644 --- a/tests/components/homekit_controller/test_climate.py +++ b/tests/components/homekit_controller/test_climate.py @@ -24,6 +24,14 @@ from tests.components.homekit_controller.common import setup_test_component HEATING_COOLING_TARGET = ("thermostat", "heating-cooling.target") HEATING_COOLING_CURRENT = ("thermostat", "heating-cooling.current") +THERMOSTAT_TEMPERATURE_COOLING_THRESHOLD = ( + "thermostat", + "temperature.cooling-threshold", +) +THERMOSTAT_TEMPERATURE_HEATING_THRESHOLD = ( + "thermostat", + "temperature.heating-threshold", +) TEMPERATURE_TARGET = ("thermostat", "temperature.target") TEMPERATURE_CURRENT = ("thermostat", "temperature.current") HUMIDITY_TARGET = ("thermostat", "relative-humidity.target") @@ -42,6 +50,16 @@ def create_thermostat_service(accessory): char = service.add_char(CharacteristicsTypes.HEATING_COOLING_CURRENT) char.value = 0 + char = service.add_char(CharacteristicsTypes.TEMPERATURE_COOLING_THRESHOLD) + char.minValue = 15 + char.maxValue = 40 + char.value = 0 + + char = service.add_char(CharacteristicsTypes.TEMPERATURE_HEATING_THRESHOLD) + char.minValue = 4 + char.maxValue = 30 + char.value = 0 + char = service.add_char(CharacteristicsTypes.TEMPERATURE_TARGET) char.minValue = 7 char.maxValue = 35 @@ -126,6 +144,41 @@ async def test_climate_change_thermostat_state(hass, utcnow): assert helper.characteristics[HEATING_COOLING_TARGET].value == 0 +async def test_climate_check_min_max_values_per_mode(hass, utcnow): + """Test that we we get the appropriate min/max values for each mode.""" + helper = await setup_test_component(hass, create_thermostat_service) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HVAC_MODE, + {"entity_id": "climate.testdevice", "hvac_mode": HVAC_MODE_HEAT}, + blocking=True, + ) + climate_state = await helper.poll_and_get_state() + assert climate_state.attributes["min_temp"] == 7 + assert climate_state.attributes["max_temp"] == 35 + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HVAC_MODE, + {"entity_id": "climate.testdevice", "hvac_mode": HVAC_MODE_COOL}, + blocking=True, + ) + climate_state = await helper.poll_and_get_state() + assert climate_state.attributes["min_temp"] == 7 + assert climate_state.attributes["max_temp"] == 35 + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HVAC_MODE, + {"entity_id": "climate.testdevice", "hvac_mode": HVAC_MODE_HEAT_COOL}, + blocking=True, + ) + climate_state = await helper.poll_and_get_state() + assert climate_state.attributes["min_temp"] == 4 + assert climate_state.attributes["max_temp"] == 40 + + async def test_climate_change_thermostat_temperature(hass, utcnow): """Test that we can turn a HomeKit thermostat on and off again.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -147,6 +200,89 @@ async def test_climate_change_thermostat_temperature(hass, utcnow): assert helper.characteristics[TEMPERATURE_TARGET].value == 25 +async def test_climate_change_thermostat_temperature_range(hass, utcnow): + """Test that we can set separate heat and cool setpoints in heat_cool mode.""" + helper = await setup_test_component(hass, create_thermostat_service) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HVAC_MODE, + {"entity_id": "climate.testdevice", "hvac_mode": HVAC_MODE_HEAT_COOL}, + blocking=True, + ) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_TEMPERATURE, + { + "entity_id": "climate.testdevice", + "hvac_mode": HVAC_MODE_HEAT_COOL, + "target_temp_high": 25, + "target_temp_low": 20, + }, + blocking=True, + ) + assert helper.characteristics[TEMPERATURE_TARGET].value == 22.5 + assert helper.characteristics[THERMOSTAT_TEMPERATURE_HEATING_THRESHOLD].value == 20 + assert helper.characteristics[THERMOSTAT_TEMPERATURE_COOLING_THRESHOLD].value == 25 + + +async def test_climate_change_thermostat_temperature_range_iphone(hass, utcnow): + """Test that we can set all three set points at once (iPhone heat_cool mode support).""" + helper = await setup_test_component(hass, create_thermostat_service) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HVAC_MODE, + {"entity_id": "climate.testdevice", "hvac_mode": HVAC_MODE_HEAT_COOL}, + blocking=True, + ) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_TEMPERATURE, + { + "entity_id": "climate.testdevice", + "hvac_mode": HVAC_MODE_HEAT_COOL, + "temperature": 22, + "target_temp_low": 20, + "target_temp_high": 24, + }, + blocking=True, + ) + assert helper.characteristics[TEMPERATURE_TARGET].value == 22 + assert helper.characteristics[THERMOSTAT_TEMPERATURE_HEATING_THRESHOLD].value == 20 + assert helper.characteristics[THERMOSTAT_TEMPERATURE_COOLING_THRESHOLD].value == 24 + + +async def test_climate_cannot_set_thermostat_temp_range_in_wrong_mode(hass, utcnow): + """Test that we cannot set range values when not in heat_cool mode.""" + helper = await setup_test_component(hass, create_thermostat_service) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HVAC_MODE, + {"entity_id": "climate.testdevice", "hvac_mode": HVAC_MODE_HEAT}, + blocking=True, + ) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_TEMPERATURE, + { + "entity_id": "climate.testdevice", + "hvac_mode": HVAC_MODE_HEAT_COOL, + "temperature": 22, + "target_temp_low": 20, + "target_temp_high": 24, + }, + blocking=True, + ) + assert helper.characteristics[TEMPERATURE_TARGET].value == 22 + assert helper.characteristics[THERMOSTAT_TEMPERATURE_HEATING_THRESHOLD].value == 0 + assert helper.characteristics[THERMOSTAT_TEMPERATURE_COOLING_THRESHOLD].value == 0 + + async def test_climate_change_thermostat_humidity(hass, utcnow): """Test that we can turn a HomeKit thermostat on and off again.""" helper = await setup_test_component(hass, create_thermostat_service)