From f69571f16476a7988f23fd9bf5d9461a1456c566 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 21 Feb 2022 22:58:31 -1000 Subject: [PATCH] Add support for climate fan and oscillate mode to HomeKit (#66463) --- .../components/homekit/type_thermostats.py | 221 +++++++++++- .../homekit/test_type_thermostats.py | 332 ++++++++++++++++++ 2 files changed, 541 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 5f925e2b01d..8c54896e85e 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -6,6 +6,8 @@ from pyhap.const import CATEGORY_THERMOSTAT from homeassistant.components.climate.const import ( ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_FAN_MODES, ATTR_HUMIDITY, ATTR_HVAC_ACTION, ATTR_HVAC_MODE, @@ -13,6 +15,8 @@ from homeassistant.components.climate.const import ( ATTR_MAX_TEMP, ATTR_MIN_HUMIDITY, ATTR_MIN_TEMP, + ATTR_SWING_MODE, + ATTR_SWING_MODES, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, CURRENT_HVAC_COOL, @@ -25,6 +29,13 @@ from homeassistant.components.climate.const import ( DEFAULT_MIN_HUMIDITY, DEFAULT_MIN_TEMP, DOMAIN as DOMAIN_CLIMATE, + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_MIDDLE, + FAN_OFF, + FAN_ON, HVAC_MODE_AUTO, HVAC_MODE_COOL, HVAC_MODE_DRY, @@ -32,12 +43,21 @@ from homeassistant.components.climate.const import ( HVAC_MODE_HEAT, HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF, + SERVICE_SET_FAN_MODE, SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE as SERVICE_SET_HVAC_MODE_THERMOSTAT, + SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE as SERVICE_SET_TEMPERATURE_THERMOSTAT, + SUPPORT_FAN_MODE, + SUPPORT_SWING_MODE, SUPPORT_TARGET_HUMIDITY, SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_RANGE, + SWING_BOTH, + SWING_HORIZONTAL, + SWING_OFF, + SWING_ON, + SWING_VERTICAL, ) from homeassistant.components.water_heater import ( DOMAIN as DOMAIN_WATER_HEATER, @@ -51,15 +71,24 @@ from homeassistant.const import ( TEMP_CELSIUS, TEMP_FAHRENHEIT, ) -from homeassistant.core import callback +from homeassistant.core import State, callback +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, +) from .accessories import TYPES, HomeAccessory from .const import ( + CHAR_ACTIVE, CHAR_COOLING_THRESHOLD_TEMPERATURE, + CHAR_CURRENT_FAN_STATE, CHAR_CURRENT_HEATING_COOLING, CHAR_CURRENT_HUMIDITY, CHAR_CURRENT_TEMPERATURE, CHAR_HEATING_THRESHOLD_TEMPERATURE, + CHAR_ROTATION_SPEED, + CHAR_SWING_MODE, + CHAR_TARGET_FAN_STATE, CHAR_TARGET_HEATING_COOLING, CHAR_TARGET_HUMIDITY, CHAR_TARGET_TEMPERATURE, @@ -67,7 +96,9 @@ from .const import ( DEFAULT_MAX_TEMP_WATER_HEATER, DEFAULT_MIN_TEMP_WATER_HEATER, PROP_MAX_VALUE, + PROP_MIN_STEP, PROP_MIN_VALUE, + SERV_FANV2, SERV_THERMOSTAT, ) from .util import temperature_to_homekit, temperature_to_states @@ -103,6 +134,11 @@ HC_HEAT_COOL_PREFER_COOL = [ HC_HEAT_COOL_OFF, ] +ORDERED_FAN_SPEEDS = [FAN_LOW, FAN_MIDDLE, FAN_MEDIUM, FAN_HIGH] +PRE_DEFINED_FAN_MODES = set(ORDERED_FAN_SPEEDS) +SWING_MODE_PREFERRED_ORDER = [SWING_ON, SWING_BOTH, SWING_HORIZONTAL, SWING_VERTICAL] +PRE_DEFINED_SWING_MODES = set(SWING_MODE_PREFERRED_ORDER) + HC_MIN_TEMP = 10 HC_MAX_TEMP = 38 @@ -127,6 +163,19 @@ HC_HASS_TO_HOMEKIT_ACTION = { CURRENT_HVAC_FAN: HC_HEAT_COOL_COOL, } +FAN_STATE_INACTIVE = 0 +FAN_STATE_IDLE = 1 +FAN_STATE_ACTIVE = 2 + +HC_HASS_TO_HOMEKIT_FAN_STATE = { + CURRENT_HVAC_OFF: FAN_STATE_INACTIVE, + CURRENT_HVAC_IDLE: FAN_STATE_IDLE, + CURRENT_HVAC_HEAT: FAN_STATE_ACTIVE, + CURRENT_HVAC_COOL: FAN_STATE_ACTIVE, + CURRENT_HVAC_DRY: FAN_STATE_ACTIVE, + CURRENT_HVAC_FAN: FAN_STATE_ACTIVE, +} + HEAT_COOL_DEADBAND = 5 @@ -144,9 +193,11 @@ class Thermostat(HomeAccessory): # Add additional characteristics if auto mode is supported self.chars = [] - state = self.hass.states.get(self.entity_id) - min_humidity = state.attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY) - features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + self.fan_chars = [] + state: State = self.hass.states.get(self.entity_id) + attributes = state.attributes + min_humidity = attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY) + features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) if features & SUPPORT_TARGET_TEMPERATURE_RANGE: self.chars.extend( @@ -157,6 +208,7 @@ class Thermostat(HomeAccessory): self.chars.extend((CHAR_TARGET_HUMIDITY, CHAR_CURRENT_HUMIDITY)) serv_thermostat = self.add_preload_service(SERV_THERMOSTAT, self.chars) + self.set_primary_service(serv_thermostat) # Current mode characteristics self.char_current_heat_cool = serv_thermostat.configure_char( @@ -233,10 +285,116 @@ class Thermostat(HomeAccessory): CHAR_CURRENT_HUMIDITY, value=50 ) + fan_modes = self.fan_modes = { + fan_mode.lower(): fan_mode + for fan_mode in attributes.get(ATTR_FAN_MODES, []) + } + self.ordered_fan_speeds = [] + if ( + features & SUPPORT_FAN_MODE + and fan_modes + and PRE_DEFINED_FAN_MODES.intersection(fan_modes) + ): + self.ordered_fan_speeds = [ + speed for speed in ORDERED_FAN_SPEEDS if speed in fan_modes + ] + self.fan_chars.append(CHAR_ROTATION_SPEED) + + if FAN_AUTO in fan_modes and (FAN_ON in fan_modes or self.ordered_fan_speeds): + self.fan_chars.append(CHAR_TARGET_FAN_STATE) + + self.fan_modes = fan_modes + if ( + features & SUPPORT_SWING_MODE + and (swing_modes := attributes.get(ATTR_SWING_MODES)) + and PRE_DEFINED_SWING_MODES.intersection(swing_modes) + ): + self.swing_on_mode = next( + iter( + swing_mode + for swing_mode in SWING_MODE_PREFERRED_ORDER + if swing_mode in swing_modes + ) + ) + self.fan_chars.append(CHAR_SWING_MODE) + + if self.fan_chars: + if attributes.get(ATTR_HVAC_ACTION) is not None: + self.fan_chars.append(CHAR_CURRENT_FAN_STATE) + serv_fan = self.add_preload_service(SERV_FANV2, self.fan_chars) + serv_thermostat.add_linked_service(serv_fan) + self.char_active = serv_fan.configure_char( + CHAR_ACTIVE, value=1, setter_callback=self._set_fan_active + ) + if CHAR_SWING_MODE in self.fan_chars: + self.char_swing = serv_fan.configure_char( + CHAR_SWING_MODE, + value=0, + setter_callback=self._set_fan_swing_mode, + ) + self.char_swing.display_name = "Swing Mode" + if CHAR_ROTATION_SPEED in self.fan_chars: + self.char_speed = serv_fan.configure_char( + CHAR_ROTATION_SPEED, + value=100, + properties={PROP_MIN_STEP: 100 / len(self.ordered_fan_speeds)}, + setter_callback=self._set_fan_speed, + ) + self.char_speed.display_name = "Fan Mode" + if CHAR_CURRENT_FAN_STATE in self.fan_chars: + self.char_current_fan_state = serv_fan.configure_char( + CHAR_CURRENT_FAN_STATE, + value=0, + ) + self.char_current_fan_state.display_name = "Fan State" + if CHAR_TARGET_FAN_STATE in self.fan_chars and FAN_AUTO in self.fan_modes: + self.char_target_fan_state = serv_fan.configure_char( + CHAR_TARGET_FAN_STATE, + value=0, + setter_callback=self._set_fan_auto, + ) + self.char_target_fan_state.display_name = "Fan Auto" + self._async_update_state(state) serv_thermostat.setter_callback = self._set_chars + def _set_fan_swing_mode(self, swing_on) -> None: + _LOGGER.debug("%s: Set swing mode to %s", self.entity_id, swing_on) + mode = self.swing_on_mode if swing_on else SWING_OFF + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_SWING_MODE: mode} + self.async_call_service(DOMAIN_CLIMATE, SERVICE_SET_SWING_MODE, params) + + def _set_fan_speed(self, speed) -> None: + _LOGGER.debug("%s: Set fan speed to %s", self.entity_id, speed) + mode = percentage_to_ordered_list_item(self.ordered_fan_speeds, speed - 1) + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode} + self.async_call_service(DOMAIN_CLIMATE, SERVICE_SET_FAN_MODE, params) + + def _get_on_mode(self) -> str: + if self.ordered_fan_speeds: + return percentage_to_ordered_list_item(self.ordered_fan_speeds, 50) + return self.fan_modes[FAN_ON] + + def _set_fan_active(self, active) -> None: + _LOGGER.debug("%s: Set fan active to %s", self.entity_id, active) + if FAN_OFF not in self.fan_modes: + _LOGGER.debug( + "%s: Fan does not support off, resetting to on", self.entity_id + ) + self.char_active.value = 1 + self.char_active.notify() + return + mode = self._get_on_mode() if active else self.fan_modes[FAN_OFF] + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode} + self.async_call_service(DOMAIN_CLIMATE, SERVICE_SET_FAN_MODE, params) + + def _set_fan_auto(self, auto) -> None: + _LOGGER.debug("%s: Set fan auto to %s", self.entity_id, auto) + mode = self.fan_modes[FAN_AUTO] if auto else self._get_on_mode() + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode} + self.async_call_service(DOMAIN_CLIMATE, SERVICE_SET_FAN_MODE, params) + def _temperature_to_homekit(self, temp): return temperature_to_homekit(temp, self._unit) @@ -446,7 +604,8 @@ class Thermostat(HomeAccessory): @callback def _async_update_state(self, new_state): """Update state without rechecking the device features.""" - features = new_state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + attributes = new_state.attributes + features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) # Update target operation mode FIRST hvac_mode = new_state.state @@ -462,7 +621,7 @@ class Thermostat(HomeAccessory): ) # Set current operation mode for supported thermostats - if hvac_action := new_state.attributes.get(ATTR_HVAC_ACTION): + if hvac_action := attributes.get(ATTR_HVAC_ACTION): homekit_hvac_action = HC_HASS_TO_HOMEKIT_ACTION[hvac_action] self.char_current_heat_cool.set_value(homekit_hvac_action) @@ -473,26 +632,26 @@ class Thermostat(HomeAccessory): # Update current humidity if CHAR_CURRENT_HUMIDITY in self.chars: - current_humdity = new_state.attributes.get(ATTR_CURRENT_HUMIDITY) + current_humdity = attributes.get(ATTR_CURRENT_HUMIDITY) if isinstance(current_humdity, (int, float)): self.char_current_humidity.set_value(current_humdity) # Update target humidity if CHAR_TARGET_HUMIDITY in self.chars: - target_humdity = new_state.attributes.get(ATTR_HUMIDITY) + target_humdity = 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) + cooling_thresh = attributes.get(ATTR_TARGET_TEMP_HIGH) if isinstance(cooling_thresh, (int, float)): cooling_thresh = self._temperature_to_homekit(cooling_thresh) self.char_cooling_thresh_temp.set_value(cooling_thresh) # Update heating threshold temperature if characteristic exists if self.char_heating_thresh_temp: - heating_thresh = new_state.attributes.get(ATTR_TARGET_TEMP_LOW) + heating_thresh = attributes.get(ATTR_TARGET_TEMP_LOW) if isinstance(heating_thresh, (int, float)): heating_thresh = self._temperature_to_homekit(heating_thresh) self.char_heating_thresh_temp.set_value(heating_thresh) @@ -504,11 +663,11 @@ class Thermostat(HomeAccessory): # even if the device does not support it hc_hvac_mode = self.char_target_heat_cool.value if hc_hvac_mode == HC_HEAT_COOL_HEAT: - temp_low = new_state.attributes.get(ATTR_TARGET_TEMP_LOW) + temp_low = attributes.get(ATTR_TARGET_TEMP_LOW) if isinstance(temp_low, (int, float)): target_temp = self._temperature_to_homekit(temp_low) elif hc_hvac_mode == HC_HEAT_COOL_COOL: - temp_high = new_state.attributes.get(ATTR_TARGET_TEMP_HIGH) + temp_high = attributes.get(ATTR_TARGET_TEMP_HIGH) if isinstance(temp_high, (int, float)): target_temp = self._temperature_to_homekit(temp_high) if target_temp: @@ -519,6 +678,44 @@ class Thermostat(HomeAccessory): unit = UNIT_HASS_TO_HOMEKIT[self._unit] self.char_display_units.set_value(unit) + if self.fan_chars: + self._async_update_fan_state(new_state) + + @callback + def _async_update_fan_state(self, new_state): + """Update state without rechecking the device features.""" + attributes = new_state.attributes + + if CHAR_SWING_MODE in self.fan_chars and ( + swing_mode := attributes.get(ATTR_SWING_MODE) + ): + swing = 1 if swing_mode in PRE_DEFINED_SWING_MODES else 0 + self.char_swing.set_value(swing) + + fan_mode = attributes.get(ATTR_FAN_MODE) + fan_mode_lower = fan_mode.lower() if isinstance(fan_mode, str) else None + if ( + CHAR_ROTATION_SPEED in self.fan_chars + and fan_mode_lower in self.ordered_fan_speeds + ): + self.char_speed.set_value( + ordered_list_item_to_percentage(self.ordered_fan_speeds, fan_mode_lower) + ) + + if CHAR_TARGET_FAN_STATE in self.fan_chars: + self.char_target_fan_state.set_value(1 if fan_mode_lower == FAN_AUTO else 0) + + if CHAR_CURRENT_FAN_STATE in self.fan_chars and ( + hvac_action := attributes.get(ATTR_HVAC_ACTION) + ): + self.char_current_fan_state.set_value( + HC_HASS_TO_HOMEKIT_FAN_STATE[hvac_action] + ) + + self.char_active.set_value( + int(new_state.state != HVAC_MODE_OFF and fan_mode_lower != FAN_OFF) + ) + @TYPES.register("WaterHeater") class WaterHeater(HomeAccessory): diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index a11aa9d6cb7..d1db618e7e4 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -7,12 +7,16 @@ import pytest from homeassistant.components.climate.const import ( ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_FAN_MODES, ATTR_HUMIDITY, ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_HVAC_MODES, ATTR_MAX_TEMP, ATTR_MIN_TEMP, + ATTR_SWING_MODE, + ATTR_SWING_MODES, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_STEP, @@ -24,6 +28,12 @@ from homeassistant.components.climate.const import ( DEFAULT_MAX_TEMP, DEFAULT_MIN_HUMIDITY, DOMAIN as DOMAIN_CLIMATE, + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_OFF, + FAN_ON, HVAC_MODE_AUTO, HVAC_MODE_COOL, HVAC_MODE_DRY, @@ -31,11 +41,22 @@ from homeassistant.components.climate.const import ( HVAC_MODE_HEAT, HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF, + SERVICE_SET_FAN_MODE, + SERVICE_SET_SWING_MODE, + SUPPORT_FAN_MODE, + SUPPORT_SWING_MODE, SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_RANGE, + SWING_BOTH, + SWING_HORIZONTAL, + SWING_OFF, ) from homeassistant.components.homekit.const import ( ATTR_VALUE, + CHAR_CURRENT_FAN_STATE, + CHAR_ROTATION_SPEED, + CHAR_SWING_MODE, + CHAR_TARGET_FAN_STATE, DEFAULT_MAX_TEMP_WATER_HEATER, DEFAULT_MIN_TEMP_WATER_HEATER, PROP_MAX_VALUE, @@ -2017,3 +2038,314 @@ async def test_thermostat_with_temp_clamps(hass, hk_driver, events): assert acc.char_target_heat_cool.value == 3 assert acc.char_current_temp.value == 1000 assert acc.char_display_units.value == 0 + + +async def test_thermostat_with_fan_modes_with_auto(hass, hk_driver, events): + """Test a thermostate with fan modes with an auto fan mode.""" + entity_id = "climate.test" + hass.states.async_set( + entity_id, + HVAC_MODE_OFF, + { + ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE + | SUPPORT_TARGET_TEMPERATURE_RANGE + | SUPPORT_FAN_MODE + | SUPPORT_SWING_MODE, + ATTR_FAN_MODES: [FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH], + ATTR_SWING_MODES: [SWING_BOTH, SWING_OFF, SWING_HORIZONTAL], + ATTR_HVAC_ACTION: CURRENT_HVAC_IDLE, + ATTR_FAN_MODE: FAN_AUTO, + ATTR_SWING_MODE: SWING_BOTH, + ATTR_HVAC_MODES: [ + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_COOL, + HVAC_MODE_OFF, + HVAC_MODE_AUTO, + ], + }, + ) + await hass.async_block_till_done() + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + await acc.run() + await hass.async_block_till_done() + + assert acc.char_cooling_thresh_temp.value == 23.0 + assert acc.char_heating_thresh_temp.value == 19.0 + assert acc.ordered_fan_speeds == [FAN_LOW, FAN_MEDIUM, FAN_HIGH] + assert CHAR_ROTATION_SPEED in acc.fan_chars + assert CHAR_TARGET_FAN_STATE in acc.fan_chars + assert CHAR_SWING_MODE in acc.fan_chars + assert CHAR_CURRENT_FAN_STATE in acc.fan_chars + assert acc.char_speed.value == 100 + + hass.states.async_set( + entity_id, + HVAC_MODE_OFF, + { + ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE + | SUPPORT_TARGET_TEMPERATURE_RANGE + | SUPPORT_FAN_MODE + | SUPPORT_SWING_MODE, + ATTR_FAN_MODES: [FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH], + ATTR_SWING_MODES: [SWING_BOTH, SWING_OFF, SWING_HORIZONTAL], + ATTR_HVAC_ACTION: CURRENT_HVAC_IDLE, + ATTR_FAN_MODE: FAN_LOW, + ATTR_SWING_MODE: SWING_BOTH, + ATTR_HVAC_MODES: [ + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_COOL, + HVAC_MODE_OFF, + HVAC_MODE_AUTO, + ], + }, + ) + await hass.async_block_till_done() + assert acc.char_speed.value == pytest.approx(100 / 3) + + call_set_swing_mode = async_mock_service( + hass, DOMAIN_CLIMATE, SERVICE_SET_SWING_MODE + ) + char_swing_iid = acc.char_swing.to_HAP()[HAP_REPR_IID] + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_swing_iid, + HAP_REPR_VALUE: 0, + } + ] + }, + "mock_addr", + ) + + await hass.async_block_till_done() + assert len(call_set_swing_mode) == 1 + assert call_set_swing_mode[-1].data[ATTR_ENTITY_ID] == entity_id + assert call_set_swing_mode[-1].data[ATTR_SWING_MODE] == SWING_OFF + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_swing_iid, + HAP_REPR_VALUE: 1, + } + ] + }, + "mock_addr", + ) + + await hass.async_block_till_done() + assert len(call_set_swing_mode) == 2 + assert call_set_swing_mode[-1].data[ATTR_ENTITY_ID] == entity_id + assert call_set_swing_mode[-1].data[ATTR_SWING_MODE] == SWING_BOTH + + call_set_fan_mode = async_mock_service(hass, DOMAIN_CLIMATE, SERVICE_SET_FAN_MODE) + char_rotation_speed_iid = acc.char_speed.to_HAP()[HAP_REPR_IID] + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_rotation_speed_iid, + HAP_REPR_VALUE: 100, + } + ] + }, + "mock_addr", + ) + + await hass.async_block_till_done() + assert len(call_set_fan_mode) == 1 + assert call_set_fan_mode[-1].data[ATTR_ENTITY_ID] == entity_id + assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == FAN_HIGH + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_rotation_speed_iid, + HAP_REPR_VALUE: 100 / 3, + } + ] + }, + "mock_addr", + ) + + await hass.async_block_till_done() + assert len(call_set_fan_mode) == 2 + assert call_set_fan_mode[-1].data[ATTR_ENTITY_ID] == entity_id + assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == FAN_LOW + + char_active_iid = acc.char_active.to_HAP()[HAP_REPR_IID] + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_active_iid, + HAP_REPR_VALUE: 0, + } + ] + }, + "mock_addr", + ) + + await hass.async_block_till_done() + assert acc.char_active.value == 1 + + char_target_fan_state_iid = acc.char_target_fan_state.to_HAP()[HAP_REPR_IID] + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_fan_state_iid, + HAP_REPR_VALUE: 1, + } + ] + }, + "mock_addr", + ) + + await hass.async_block_till_done() + assert len(call_set_fan_mode) == 3 + assert call_set_fan_mode[-1].data[ATTR_ENTITY_ID] == entity_id + assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == FAN_AUTO + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_fan_state_iid, + HAP_REPR_VALUE: 0, + } + ] + }, + "mock_addr", + ) + + await hass.async_block_till_done() + assert len(call_set_fan_mode) == 4 + assert call_set_fan_mode[-1].data[ATTR_ENTITY_ID] == entity_id + assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == FAN_MEDIUM + + +async def test_thermostat_with_fan_modes_with_off(hass, hk_driver, events): + """Test a thermostate with fan modes that can turn off.""" + entity_id = "climate.test" + hass.states.async_set( + entity_id, + HVAC_MODE_COOL, + { + ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE + | SUPPORT_TARGET_TEMPERATURE_RANGE + | SUPPORT_FAN_MODE + | SUPPORT_SWING_MODE, + ATTR_FAN_MODES: [FAN_ON, FAN_OFF], + ATTR_SWING_MODES: [SWING_BOTH, SWING_OFF, SWING_HORIZONTAL], + ATTR_HVAC_ACTION: CURRENT_HVAC_IDLE, + ATTR_FAN_MODE: FAN_ON, + ATTR_SWING_MODE: SWING_BOTH, + ATTR_HVAC_MODES: [ + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_COOL, + HVAC_MODE_OFF, + HVAC_MODE_AUTO, + ], + }, + ) + await hass.async_block_till_done() + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + await acc.run() + await hass.async_block_till_done() + + assert acc.char_cooling_thresh_temp.value == 23.0 + assert acc.char_heating_thresh_temp.value == 19.0 + assert acc.ordered_fan_speeds == [] + assert CHAR_ROTATION_SPEED not in acc.fan_chars + assert CHAR_TARGET_FAN_STATE not in acc.fan_chars + assert CHAR_SWING_MODE in acc.fan_chars + assert CHAR_CURRENT_FAN_STATE in acc.fan_chars + assert acc.char_active.value == 1 + + hass.states.async_set( + entity_id, + HVAC_MODE_COOL, + { + ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE + | SUPPORT_TARGET_TEMPERATURE_RANGE + | SUPPORT_FAN_MODE + | SUPPORT_SWING_MODE, + ATTR_FAN_MODES: [FAN_ON, FAN_OFF], + ATTR_SWING_MODES: [SWING_BOTH, SWING_OFF, SWING_HORIZONTAL], + ATTR_HVAC_ACTION: CURRENT_HVAC_IDLE, + ATTR_FAN_MODE: FAN_OFF, + ATTR_SWING_MODE: SWING_BOTH, + ATTR_HVAC_MODES: [ + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_COOL, + HVAC_MODE_OFF, + HVAC_MODE_AUTO, + ], + }, + ) + await hass.async_block_till_done() + assert acc.char_active.value == 0 + + call_set_fan_mode = async_mock_service(hass, DOMAIN_CLIMATE, SERVICE_SET_FAN_MODE) + char_active_iid = acc.char_active.to_HAP()[HAP_REPR_IID] + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_active_iid, + HAP_REPR_VALUE: 1, + } + ] + }, + "mock_addr", + ) + + await hass.async_block_till_done() + assert len(call_set_fan_mode) == 1 + assert call_set_fan_mode[-1].data[ATTR_ENTITY_ID] == entity_id + assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == FAN_ON + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_active_iid, + HAP_REPR_VALUE: 0, + } + ] + }, + "mock_addr", + ) + + await hass.async_block_till_done() + assert len(call_set_fan_mode) == 2 + assert call_set_fan_mode[-1].data[ATTR_ENTITY_ID] == entity_id + assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == FAN_OFF