From 677fc6e2bb4a844a3230e6f86cfeaaddab6494d3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 23 Dec 2020 17:23:26 -1000 Subject: [PATCH] Translate siri requests to turn on thermostats to valid targets (#44236) Siri always requests auto mode even when the device does not support auto which results in the thermostat failing to turn on as success is assumed. We now determine the heat cool target mode based on the current temp, target temp, and supported modes to ensure the thermostat will reach the requested target temp. --- .../components/homekit/type_thermostats.py | 92 +++-- .../homekit/test_type_thermostats.py | 350 ++++++++++++++---- 2 files changed, 356 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 6d0f5f22d79..54e2e9f92a8 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -89,6 +89,20 @@ HC_HEAT_COOL_HEAT = 1 HC_HEAT_COOL_COOL = 2 HC_HEAT_COOL_AUTO = 3 +HC_HEAT_COOL_PREFER_HEAT = [ + HC_HEAT_COOL_AUTO, + HC_HEAT_COOL_HEAT, + HC_HEAT_COOL_COOL, + HC_HEAT_COOL_OFF, +] + +HC_HEAT_COOL_PREFER_COOL = [ + HC_HEAT_COOL_AUTO, + HC_HEAT_COOL_COOL, + HC_HEAT_COOL_HEAT, + HC_HEAT_COOL_OFF, +] + HC_MIN_TEMP = 10 HC_MAX_TEMP = 38 @@ -236,7 +250,7 @@ class Thermostat(HomeAccessory): state = self.hass.states.get(self.entity_id) features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - hvac_mode = self.hass.states.get(self.entity_id).state + hvac_mode = state.state homekit_hvac_mode = HC_HASS_TO_HOMEKIT[hvac_mode] if CHAR_TARGET_HEATING_COOLING in char_values: @@ -244,19 +258,37 @@ class Thermostat(HomeAccessory): # Ignore it if its the same mode if char_values[CHAR_TARGET_HEATING_COOLING] != homekit_hvac_mode: target_hc = char_values[CHAR_TARGET_HEATING_COOLING] - if target_hc in self.hc_homekit_to_hass: - service = SERVICE_SET_HVAC_MODE_THERMOSTAT - hass_value = self.hc_homekit_to_hass[target_hc] - params = {ATTR_HVAC_MODE: hass_value} - events.append( - f"{CHAR_TARGET_HEATING_COOLING} to {char_values[CHAR_TARGET_HEATING_COOLING]}" - ) - else: - _LOGGER.warning( - "The entity: %s does not have a %s mode", - self.entity_id, - target_hc, - ) + if target_hc not in self.hc_homekit_to_hass: + # If the target heating cooling state we want does not + # exist on the device, we have to sort it out + # based on the the current and target temperature since + # siri will always send HC_HEAT_COOL_AUTO in this case + # and hope for the best. + hc_target_temp = char_values.get(CHAR_TARGET_TEMPERATURE) + hc_current_temp = _get_current_temperature(state, self._unit) + hc_fallback_order = HC_HEAT_COOL_PREFER_HEAT + if ( + hc_target_temp is not None + and hc_current_temp is not None + and hc_target_temp < hc_current_temp + ): + hc_fallback_order = HC_HEAT_COOL_PREFER_COOL + for hc_fallback in hc_fallback_order: + if hc_fallback in self.hc_homekit_to_hass: + _LOGGER.debug( + "Siri requested target mode: %s and the device does not support, falling back to %s", + target_hc, + hc_fallback, + ) + target_hc = hc_fallback + break + + service = SERVICE_SET_HVAC_MODE_THERMOSTAT + hass_value = self.hc_homekit_to_hass[target_hc] + params = {ATTR_HVAC_MODE: hass_value} + events.append( + f"{CHAR_TARGET_HEATING_COOLING} to {char_values[CHAR_TARGET_HEATING_COOLING]}" + ) if CHAR_TARGET_TEMPERATURE in char_values: hc_target_temp = char_values[CHAR_TARGET_TEMPERATURE] @@ -429,9 +461,8 @@ class Thermostat(HomeAccessory): self.char_current_heat_cool.set_value(homekit_hvac_action) # Update current temperature - current_temp = new_state.attributes.get(ATTR_CURRENT_TEMPERATURE) - if isinstance(current_temp, (int, float)): - current_temp = self._temperature_to_homekit(current_temp) + current_temp = _get_current_temperature(new_state, self._unit) + if current_temp is not None: if self.char_current_temp.value != current_temp: self.char_current_temp.set_value(current_temp) @@ -466,10 +497,8 @@ class Thermostat(HomeAccessory): self.char_heating_thresh_temp.set_value(heating_thresh) # Update target temperature - target_temp = new_state.attributes.get(ATTR_TEMPERATURE) - if isinstance(target_temp, (int, float)): - target_temp = self._temperature_to_homekit(target_temp) - elif features & SUPPORT_TARGET_TEMPERATURE_RANGE: + target_temp = _get_target_temperature(new_state, self._unit) + if target_temp is None and features & SUPPORT_TARGET_TEMPERATURE_RANGE: # Homekit expects a target temperature # even if the device does not support it hc_hvac_mode = self.char_target_heat_cool.value @@ -566,9 +595,8 @@ class WaterHeater(HomeAccessory): def async_update_state(self, new_state): """Update water_heater state after state change.""" # Update current and target temperature - temperature = new_state.attributes.get(ATTR_TEMPERATURE) - if isinstance(temperature, (int, float)): - temperature = temperature_to_homekit(temperature, self._unit) + temperature = _get_target_temperature(new_state, self._unit) + if temperature is not None: if temperature != self.char_current_temp.value: self.char_target_temp.set_value(temperature) @@ -606,3 +634,19 @@ def _get_temperature_range_from_state(state, unit, default_min, default_max): max_temp = min_temp return min_temp, max_temp + + +def _get_target_temperature(state, unit): + """Calculate the target temperature from a state.""" + target_temp = state.attributes.get(ATTR_TEMPERATURE) + if isinstance(target_temp, (int, float)): + return temperature_to_homekit(target_temp, unit) + return None + + +def _get_current_temperature(state, unit): + """Calculate the current temperature from a state.""" + target_temp = state.attributes.get(ATTR_CURRENT_TEMPERATURE) + if isinstance(target_temp, (int, float)): + return temperature_to_homekit(target_temp, unit) + return None diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index e371fa6fe25..acb45bca85f 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -1,6 +1,4 @@ """Test different accessory types: Thermostats.""" -from collections import namedtuple - from pyhap.const import HAP_REPR_AID, HAP_REPR_CHARS, HAP_REPR_IID, HAP_REPR_VALUE import pytest @@ -42,6 +40,14 @@ from homeassistant.components.homekit.const import ( PROP_MIN_STEP, PROP_MIN_VALUE, ) +from homeassistant.components.homekit.type_thermostats import ( + HC_HEAT_COOL_AUTO, + HC_HEAT_COOL_COOL, + HC_HEAT_COOL_HEAT, + HC_HEAT_COOL_OFF, + Thermostat, + WaterHeater, +) from homeassistant.components.water_heater import DOMAIN as DOMAIN_WATER_HEATER from homeassistant.const import ( ATTR_ENTITY_ID, @@ -57,24 +63,9 @@ from homeassistant.helpers import entity_registry from tests.async_mock import patch from tests.common import async_mock_service -from tests.components.homekit.common import patch_debounce -@pytest.fixture(scope="module") -def cls(): - """Patch debounce decorator during import of type_thermostats.""" - patcher = patch_debounce() - patcher.start() - _import = __import__( - "homeassistant.components.homekit.type_thermostats", - fromlist=["WaterHeater", "Thermostat"], - ) - patcher_tuple = namedtuple("Cls", ["water_heater", "thermostat"]) - yield patcher_tuple(thermostat=_import.Thermostat, water_heater=_import.WaterHeater) - patcher.stop() - - -async def test_thermostat(hass, hk_driver, cls, events): +async def test_thermostat(hass, hk_driver, events): """Test if accessory and HA are updated accordingly.""" entity_id = "climate.test" @@ -94,7 +85,7 @@ async def test_thermostat(hass, hk_driver, cls, events): }, ) await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) await acc.run_handler() @@ -414,7 +405,7 @@ async def test_thermostat(hass, hk_driver, cls, events): assert events[-1].data[ATTR_VALUE] == "TargetHeatingCoolingState to 3" -async def test_thermostat_auto(hass, hk_driver, cls, events): +async def test_thermostat_auto(hass, hk_driver, events): """Test if accessory and HA are updated accordingly.""" entity_id = "climate.test" @@ -436,7 +427,7 @@ async def test_thermostat_auto(hass, hk_driver, cls, events): }, ) await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) await acc.run_handler() @@ -568,14 +559,14 @@ async def test_thermostat_auto(hass, hk_driver, cls, events): ) -async def test_thermostat_humidity(hass, hk_driver, cls, events): +async def test_thermostat_humidity(hass, hk_driver, 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, 1, None) + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) await acc.run_handler() @@ -627,7 +618,7 @@ async def test_thermostat_humidity(hass, hk_driver, cls, events): assert events[-1].data[ATTR_VALUE] == "35%" -async def test_thermostat_power_state(hass, hk_driver, cls, events): +async def test_thermostat_power_state(hass, hk_driver, events): """Test if accessory and HA are updated accordingly.""" entity_id = "climate.test" @@ -650,7 +641,7 @@ async def test_thermostat_power_state(hass, hk_driver, cls, events): }, ) await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) await acc.run_handler() @@ -747,7 +738,7 @@ async def test_thermostat_power_state(hass, hk_driver, cls, events): assert acc.char_target_heat_cool.value == 2 -async def test_thermostat_fahrenheit(hass, hk_driver, cls, events): +async def test_thermostat_fahrenheit(hass, hk_driver, events): """Test if accessory and HA are updated accordingly.""" entity_id = "climate.test" @@ -762,7 +753,7 @@ async def test_thermostat_fahrenheit(hass, hk_driver, cls, events): ) await hass.async_block_till_done() with patch.object(hass.config.units, CONF_TEMPERATURE_UNIT, new=TEMP_FAHRENHEIT): - acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) await acc.run_handler() await hass.async_block_till_done() @@ -856,13 +847,13 @@ async def test_thermostat_fahrenheit(hass, hk_driver, cls, events): assert events[-1].data[ATTR_VALUE] == "TargetTemperature to 24.0°C" -async def test_thermostat_get_temperature_range(hass, hk_driver, cls): +async def test_thermostat_get_temperature_range(hass, hk_driver): """Test if temperature range is evaluated correctly.""" entity_id = "climate.test" hass.states.async_set(entity_id, HVAC_MODE_OFF) await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 2, None) + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 2, None) hass.states.async_set( entity_id, HVAC_MODE_OFF, {ATTR_MIN_TEMP: 20, ATTR_MAX_TEMP: 25} @@ -878,13 +869,13 @@ async def test_thermostat_get_temperature_range(hass, hk_driver, cls): assert acc.get_temperature_range() == (15.5, 21.0) -async def test_thermostat_temperature_step_whole(hass, hk_driver, cls): +async def test_thermostat_temperature_step_whole(hass, hk_driver): """Test climate device with single digit precision.""" entity_id = "climate.test" hass.states.async_set(entity_id, HVAC_MODE_OFF, {ATTR_TARGET_TEMP_STEP: 1}) await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) await acc.run_handler() @@ -893,7 +884,7 @@ async def test_thermostat_temperature_step_whole(hass, hk_driver, cls): assert acc.char_target_temp.properties[PROP_MIN_STEP] == 0.1 -async def test_thermostat_restore(hass, hk_driver, cls, events): +async def test_thermostat_restore(hass, hk_driver, events): """Test setting up an entity from state in the event registry.""" hass.state = CoreState.not_running @@ -919,7 +910,7 @@ async def test_thermostat_restore(hass, hk_driver, cls, events): hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {}) await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, "Climate", "climate.simple", 2, None) + acc = Thermostat(hass, hk_driver, "Climate", "climate.simple", 2, None) assert acc.category == 9 assert acc.get_temperature_range() == (7, 35) assert set(acc.char_target_heat_cool.properties["ValidValues"].keys()) == { @@ -929,7 +920,7 @@ async def test_thermostat_restore(hass, hk_driver, cls, events): "off", } - acc = cls.thermostat(hass, hk_driver, "Climate", "climate.all_info_set", 2, None) + acc = Thermostat(hass, hk_driver, "Climate", "climate.all_info_set", 2, None) assert acc.category == 9 assert acc.get_temperature_range() == (60.0, 70.0) assert set(acc.char_target_heat_cool.properties["ValidValues"].keys()) == { @@ -938,7 +929,7 @@ async def test_thermostat_restore(hass, hk_driver, cls, events): } -async def test_thermostat_hvac_modes(hass, hk_driver, cls): +async def test_thermostat_hvac_modes(hass, hk_driver): """Test if unsupported HVAC modes are deactivated in HomeKit.""" entity_id = "climate.test" @@ -947,7 +938,7 @@ async def test_thermostat_hvac_modes(hass, hk_driver, cls): ) await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) await acc.run_handler() @@ -971,7 +962,7 @@ async def test_thermostat_hvac_modes(hass, hk_driver, cls): assert acc.char_target_heat_cool.value == 1 -async def test_thermostat_hvac_modes_with_auto_heat_cool(hass, hk_driver, cls): +async def test_thermostat_hvac_modes_with_auto_heat_cool(hass, hk_driver): """Test we get heat cool over auto.""" entity_id = "climate.test" @@ -990,7 +981,7 @@ async def test_thermostat_hvac_modes_with_auto_heat_cool(hass, hk_driver, cls): call_set_hvac_mode = async_mock_service(hass, DOMAIN_CLIMATE, "set_hvac_mode") await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) await acc.run_handler() @@ -1034,7 +1025,7 @@ async def test_thermostat_hvac_modes_with_auto_heat_cool(hass, hk_driver, cls): assert acc.char_target_heat_cool.value == 3 -async def test_thermostat_hvac_modes_with_auto_no_heat_cool(hass, hk_driver, cls): +async def test_thermostat_hvac_modes_with_auto_no_heat_cool(hass, hk_driver): """Test we get auto when there is no heat cool.""" entity_id = "climate.test" @@ -1046,7 +1037,7 @@ async def test_thermostat_hvac_modes_with_auto_no_heat_cool(hass, hk_driver, cls call_set_hvac_mode = async_mock_service(hass, DOMAIN_CLIMATE, "set_hvac_mode") await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) await acc.run_handler() @@ -1069,7 +1060,8 @@ async def test_thermostat_hvac_modes_with_auto_no_heat_cool(hass, hk_driver, cls assert acc.char_target_heat_cool.value == 1 char_target_heat_cool_iid = acc.char_target_heat_cool.to_HAP()[HAP_REPR_IID] - + call_set_hvac_mode = async_mock_service(hass, DOMAIN_CLIMATE, "set_hvac_mode") + await hass.async_block_till_done() hk_driver.set_characteristics( { HAP_REPR_CHARS: [ @@ -1090,7 +1082,7 @@ async def test_thermostat_hvac_modes_with_auto_no_heat_cool(hass, hk_driver, cls assert acc.char_target_heat_cool.value == 3 -async def test_thermostat_hvac_modes_with_auto_only(hass, hk_driver, cls): +async def test_thermostat_hvac_modes_with_auto_only(hass, hk_driver): """Test if unsupported HVAC modes are deactivated in HomeKit.""" entity_id = "climate.test" @@ -1099,7 +1091,7 @@ async def test_thermostat_hvac_modes_with_auto_only(hass, hk_driver, cls): ) await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) await acc.run_handler() @@ -1122,8 +1114,242 @@ async def test_thermostat_hvac_modes_with_auto_only(hass, hk_driver, cls): await hass.async_block_till_done() assert acc.char_target_heat_cool.value == 3 + char_target_heat_cool_iid = acc.char_target_heat_cool.to_HAP()[HAP_REPR_IID] + call_set_hvac_mode = async_mock_service(hass, DOMAIN_CLIMATE, "set_hvac_mode") + await hass.async_block_till_done() + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_heat_cool_iid, + HAP_REPR_VALUE: HC_HEAT_COOL_HEAT, + }, + ] + }, + "mock_addr", + ) -async def test_thermostat_hvac_modes_without_off(hass, hk_driver, cls): + await hass.async_block_till_done() + assert call_set_hvac_mode + assert call_set_hvac_mode[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVAC_MODE_AUTO + + +async def test_thermostat_hvac_modes_with_heat_only(hass, hk_driver): + """Test if unsupported HVAC modes are deactivated in HomeKit and siri calls get converted to heat.""" + entity_id = "climate.test" + + hass.states.async_set( + entity_id, HVAC_MODE_HEAT, {ATTR_HVAC_MODES: [HVAC_MODE_HEAT, HVAC_MODE_OFF]} + ) + + await hass.async_block_till_done() + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + await acc.run_handler() + await hass.async_block_till_done() + hap = acc.char_target_heat_cool.to_HAP() + assert hap["valid-values"] == [HC_HEAT_COOL_OFF, HC_HEAT_COOL_HEAT] + assert acc.char_target_heat_cool.value == HC_HEAT_COOL_HEAT + + await hass.async_add_executor_job( + acc.char_target_heat_cool.set_value, HC_HEAT_COOL_HEAT + ) + await hass.async_block_till_done() + assert acc.char_target_heat_cool.value == HC_HEAT_COOL_HEAT + + with pytest.raises(ValueError): + await hass.async_add_executor_job( + acc.char_target_heat_cool.set_value, HC_HEAT_COOL_COOL + ) + await hass.async_block_till_done() + assert acc.char_target_heat_cool.value == HC_HEAT_COOL_HEAT + + with pytest.raises(ValueError): + await hass.async_add_executor_job( + acc.char_target_heat_cool.set_value, HC_HEAT_COOL_AUTO + ) + await hass.async_block_till_done() + assert acc.char_target_heat_cool.value == HC_HEAT_COOL_HEAT + + char_target_heat_cool_iid = acc.char_target_heat_cool.to_HAP()[HAP_REPR_IID] + call_set_hvac_mode = async_mock_service(hass, DOMAIN_CLIMATE, "set_hvac_mode") + await hass.async_block_till_done() + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_heat_cool_iid, + HAP_REPR_VALUE: HC_HEAT_COOL_AUTO, + }, + ] + }, + "mock_addr", + ) + + await hass.async_block_till_done() + assert call_set_hvac_mode + assert call_set_hvac_mode[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVAC_MODE_HEAT + + +async def test_thermostat_hvac_modes_with_cool_only(hass, hk_driver): + """Test if unsupported HVAC modes are deactivated in HomeKit and siri calls get converted to cool.""" + entity_id = "climate.test" + + hass.states.async_set( + entity_id, HVAC_MODE_COOL, {ATTR_HVAC_MODES: [HVAC_MODE_COOL, HVAC_MODE_OFF]} + ) + + await hass.async_block_till_done() + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + await acc.run_handler() + await hass.async_block_till_done() + hap = acc.char_target_heat_cool.to_HAP() + assert hap["valid-values"] == [HC_HEAT_COOL_OFF, HC_HEAT_COOL_COOL] + assert acc.char_target_heat_cool.value == HC_HEAT_COOL_COOL + + await hass.async_add_executor_job( + acc.char_target_heat_cool.set_value, HC_HEAT_COOL_COOL + ) + await hass.async_block_till_done() + assert acc.char_target_heat_cool.value == HC_HEAT_COOL_COOL + + with pytest.raises(ValueError): + await hass.async_add_executor_job( + acc.char_target_heat_cool.set_value, HC_HEAT_COOL_AUTO + ) + await hass.async_block_till_done() + assert acc.char_target_heat_cool.value == HC_HEAT_COOL_COOL + + with pytest.raises(ValueError): + await hass.async_add_executor_job( + acc.char_target_heat_cool.set_value, HC_HEAT_COOL_HEAT + ) + await hass.async_block_till_done() + assert acc.char_target_heat_cool.value == HC_HEAT_COOL_COOL + + char_target_heat_cool_iid = acc.char_target_heat_cool.to_HAP()[HAP_REPR_IID] + call_set_hvac_mode = async_mock_service(hass, DOMAIN_CLIMATE, "set_hvac_mode") + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_heat_cool_iid, + HAP_REPR_VALUE: HC_HEAT_COOL_AUTO, + }, + ] + }, + "mock_addr", + ) + + await hass.async_block_till_done() + assert call_set_hvac_mode + assert call_set_hvac_mode[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVAC_MODE_COOL + + +async def test_thermostat_hvac_modes_with_heat_cool_only(hass, hk_driver): + """Test if unsupported HVAC modes are deactivated in HomeKit and siri calls get converted to heat or cool.""" + entity_id = "climate.test" + + hass.states.async_set( + entity_id, + HVAC_MODE_COOL, + { + ATTR_CURRENT_TEMPERATURE: 30, + ATTR_HVAC_MODES: [HVAC_MODE_HEAT, HVAC_MODE_COOL, HVAC_MODE_OFF], + }, + ) + + await hass.async_block_till_done() + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + await acc.run_handler() + await hass.async_block_till_done() + hap = acc.char_target_heat_cool.to_HAP() + assert hap["valid-values"] == [ + HC_HEAT_COOL_OFF, + HC_HEAT_COOL_HEAT, + HC_HEAT_COOL_COOL, + ] + assert acc.char_target_heat_cool.value == HC_HEAT_COOL_COOL + + await hass.async_add_executor_job( + acc.char_target_heat_cool.set_value, HC_HEAT_COOL_COOL + ) + await hass.async_block_till_done() + assert acc.char_target_heat_cool.value == HC_HEAT_COOL_COOL + + with pytest.raises(ValueError): + await hass.async_add_executor_job( + acc.char_target_heat_cool.set_value, HC_HEAT_COOL_AUTO + ) + await hass.async_block_till_done() + assert acc.char_target_heat_cool.value == HC_HEAT_COOL_COOL + + await hass.async_add_executor_job( + acc.char_target_heat_cool.set_value, HC_HEAT_COOL_HEAT + ) + await hass.async_block_till_done() + assert acc.char_target_heat_cool.value == HC_HEAT_COOL_HEAT + char_target_temp_iid = acc.char_target_temp.to_HAP()[HAP_REPR_IID] + char_target_heat_cool_iid = acc.char_target_heat_cool.to_HAP()[HAP_REPR_IID] + call_set_hvac_mode = async_mock_service(hass, DOMAIN_CLIMATE, "set_hvac_mode") + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_heat_cool_iid, + HAP_REPR_VALUE: HC_HEAT_COOL_AUTO, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_temp_iid, + HAP_REPR_VALUE: 0, + }, + ] + }, + "mock_addr", + ) + + await hass.async_block_till_done() + assert call_set_hvac_mode + assert call_set_hvac_mode[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVAC_MODE_COOL + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_heat_cool_iid, + HAP_REPR_VALUE: HC_HEAT_COOL_AUTO, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_temp_iid, + HAP_REPR_VALUE: 200, + }, + ] + }, + "mock_addr", + ) + + await hass.async_block_till_done() + assert call_set_hvac_mode + assert call_set_hvac_mode[1].data[ATTR_ENTITY_ID] == entity_id + assert call_set_hvac_mode[1].data[ATTR_HVAC_MODE] == HVAC_MODE_HEAT + + +async def test_thermostat_hvac_modes_without_off(hass, hk_driver): """Test a thermostat that has no off.""" entity_id = "climate.test" @@ -1132,7 +1358,7 @@ async def test_thermostat_hvac_modes_without_off(hass, hk_driver, cls): ) await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) await acc.run_handler() @@ -1160,7 +1386,7 @@ async def test_thermostat_hvac_modes_without_off(hass, hk_driver, cls): assert acc.char_target_heat_cool.value == 1 -async def test_thermostat_without_target_temp_only_range(hass, hk_driver, cls, events): +async def test_thermostat_without_target_temp_only_range(hass, hk_driver, events): """Test a thermostat that only supports a range.""" entity_id = "climate.test" @@ -1171,7 +1397,7 @@ async def test_thermostat_without_target_temp_only_range(hass, hk_driver, cls, e {ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE_RANGE}, ) await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) await acc.run_handler() @@ -1342,13 +1568,13 @@ async def test_thermostat_without_target_temp_only_range(hass, hk_driver, cls, e assert events[-1].data[ATTR_VALUE] == "HeatingThresholdTemperature to 27.0°C" -async def test_water_heater(hass, hk_driver, cls, events): +async def test_water_heater(hass, hk_driver, events): """Test if accessory and HA are updated accordingly.""" entity_id = "water_heater.test" hass.states.async_set(entity_id, HVAC_MODE_HEAT) await hass.async_block_till_done() - acc = cls.water_heater(hass, hk_driver, "WaterHeater", entity_id, 2, None) + acc = WaterHeater(hass, hk_driver, "WaterHeater", entity_id, 2, None) await acc.run_handler() await hass.async_block_till_done() @@ -1416,14 +1642,14 @@ async def test_water_heater(hass, hk_driver, cls, events): assert acc.char_target_heat_cool.value == 1 -async def test_water_heater_fahrenheit(hass, hk_driver, cls, events): +async def test_water_heater_fahrenheit(hass, hk_driver, events): """Test if accessory and HA are update accordingly.""" entity_id = "water_heater.test" hass.states.async_set(entity_id, HVAC_MODE_HEAT) await hass.async_block_till_done() with patch.object(hass.config.units, CONF_TEMPERATURE_UNIT, new=TEMP_FAHRENHEIT): - acc = cls.water_heater(hass, hk_driver, "WaterHeater", entity_id, 2, None) + acc = WaterHeater(hass, hk_driver, "WaterHeater", entity_id, 2, None) await acc.run_handler() await hass.async_block_till_done() @@ -1448,13 +1674,13 @@ async def test_water_heater_fahrenheit(hass, hk_driver, cls, events): assert events[-1].data[ATTR_VALUE] == "140.0°F" -async def test_water_heater_get_temperature_range(hass, hk_driver, cls): +async def test_water_heater_get_temperature_range(hass, hk_driver): """Test if temperature range is evaluated correctly.""" entity_id = "water_heater.test" hass.states.async_set(entity_id, HVAC_MODE_HEAT) await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, "WaterHeater", entity_id, 2, None) + acc = WaterHeater(hass, hk_driver, "WaterHeater", entity_id, 2, None) hass.states.async_set( entity_id, HVAC_MODE_HEAT, {ATTR_MIN_TEMP: 20, ATTR_MAX_TEMP: 25} @@ -1470,7 +1696,7 @@ async def test_water_heater_get_temperature_range(hass, hk_driver, cls): assert acc.get_temperature_range() == (15.5, 21.0) -async def test_water_heater_restore(hass, hk_driver, cls, events): +async def test_water_heater_restore(hass, hk_driver, events): """Test setting up an entity from state in the event registry.""" hass.state = CoreState.not_running @@ -1492,7 +1718,7 @@ async def test_water_heater_restore(hass, hk_driver, cls, events): hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {}) await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, "WaterHeater", "water_heater.simple", 2, None) + acc = Thermostat(hass, hk_driver, "WaterHeater", "water_heater.simple", 2, None) assert acc.category == 9 assert acc.get_temperature_range() == (7, 35) assert set(acc.char_current_heat_cool.properties["ValidValues"].keys()) == { @@ -1501,7 +1727,7 @@ async def test_water_heater_restore(hass, hk_driver, cls, events): "Off", } - acc = cls.thermostat( + acc = WaterHeater( hass, hk_driver, "WaterHeater", "water_heater.all_info_set", 2, None ) assert acc.category == 9 @@ -1513,7 +1739,7 @@ async def test_water_heater_restore(hass, hk_driver, cls, events): } -async def test_thermostat_with_no_modes_when_we_first_see(hass, hk_driver, cls, events): +async def test_thermostat_with_no_modes_when_we_first_see(hass, hk_driver, events): """Test if a thermostat that is not ready when we first see it.""" entity_id = "climate.test" @@ -1528,7 +1754,7 @@ async def test_thermostat_with_no_modes_when_we_first_see(hass, hk_driver, cls, }, ) await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) await acc.run_handler() @@ -1566,7 +1792,7 @@ async def test_thermostat_with_no_modes_when_we_first_see(hass, hk_driver, cls, assert acc.char_display_units.value == 0 -async def test_thermostat_with_no_off_after_recheck(hass, hk_driver, cls, events): +async def test_thermostat_with_no_off_after_recheck(hass, hk_driver, events): """Test if a thermostat that is not ready when we first see it that actually does not have off.""" entity_id = "climate.test" @@ -1581,7 +1807,7 @@ async def test_thermostat_with_no_off_after_recheck(hass, hk_driver, cls, events }, ) await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) await acc.run_handler() @@ -1619,7 +1845,7 @@ async def test_thermostat_with_no_off_after_recheck(hass, hk_driver, cls, events assert acc.char_display_units.value == 0 -async def test_thermostat_with_temp_clamps(hass, hk_driver, cls, events): +async def test_thermostat_with_temp_clamps(hass, hk_driver, events): """Test that tempatures are clamped to valid values to prevent homekit crash.""" entity_id = "climate.test" @@ -1635,7 +1861,7 @@ async def test_thermostat_with_temp_clamps(hass, hk_driver, cls, events): }, ) await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) await acc.run_handler()