diff --git a/homeassistant/components/nest/climate_sdm.py b/homeassistant/components/nest/climate_sdm.py index 8a56f78028b..6ee988b714f 100644 --- a/homeassistant/components/nest/climate_sdm.py +++ b/homeassistant/components/nest/climate_sdm.py @@ -70,6 +70,7 @@ FAN_MODE_MAP = { "OFF": FAN_OFF, } FAN_INV_MODE_MAP = {v: k for k, v in FAN_MODE_MAP.items()} +FAN_INV_MODES = list(FAN_INV_MODE_MAP) MAX_FAN_DURATION = 43200 # 15 hours is the max in the SDM API MIN_TEMP = 10 @@ -99,7 +100,7 @@ class ThermostatEntity(ClimateEntity): """Initialize ThermostatEntity.""" self._device = device self._device_info = NestDeviceInfo(device) - self._supported_features = 0 + self._attr_supported_features = 0 @property def should_poll(self) -> bool: @@ -124,7 +125,7 @@ class ThermostatEntity(ClimateEntity): async def async_added_to_hass(self) -> None: """Run when entity is added to register update signal handler.""" - self._supported_features = self._get_supported_features() + self._attr_supported_features = self._get_supported_features() self.async_on_remove( self._device.add_update_listener(self.async_write_ha_state) ) @@ -198,8 +199,6 @@ class ThermostatEntity(ClimateEntity): trait = self._device.traits[ThermostatModeTrait.NAME] if trait.mode in THERMOSTAT_MODE_MAP: hvac_mode = THERMOSTAT_MODE_MAP[trait.mode] - if hvac_mode == HVACMode.OFF and self.fan_mode == FAN_ON: - hvac_mode = HVACMode.FAN_ONLY return hvac_mode @property @@ -209,8 +208,6 @@ class ThermostatEntity(ClimateEntity): for mode in self._get_device_hvac_modes: if mode in THERMOSTAT_MODE_MAP: supported_modes.append(THERMOSTAT_MODE_MAP[mode]) - if self.supported_features & ClimateEntityFeature.FAN_MODE: - supported_modes.append(HVACMode.FAN_ONLY) return supported_modes @property @@ -252,7 +249,10 @@ class ThermostatEntity(ClimateEntity): @property def fan_mode(self) -> str: """Return the current fan mode.""" - if FanTrait.NAME in self._device.traits: + if ( + self.supported_features & ClimateEntityFeature.FAN_MODE + and FanTrait.NAME in self._device.traits + ): trait = self._device.traits[FanTrait.NAME] return FAN_MODE_MAP.get(trait.timer_mode, FAN_OFF) return FAN_OFF @@ -260,15 +260,12 @@ class ThermostatEntity(ClimateEntity): @property def fan_modes(self) -> list[str]: """Return the list of available fan modes.""" - modes = [] - if FanTrait.NAME in self._device.traits: - modes = list(FAN_INV_MODE_MAP) - return modes - - @property - def supported_features(self) -> int: - """Bitmap of supported features.""" - return self._supported_features + if ( + self.supported_features & ClimateEntityFeature.FAN_MODE + and FanTrait.NAME in self._device.traits + ): + return FAN_INV_MODES + return [] def _get_supported_features(self) -> int: """Compute the bitmap of supported features from the current state.""" @@ -290,10 +287,6 @@ class ThermostatEntity(ClimateEntity): """Set new target hvac mode.""" if hvac_mode not in self.hvac_modes: raise ValueError(f"Unsupported hvac_mode '{hvac_mode}'") - if hvac_mode == HVACMode.FAN_ONLY: - # Turn the fan on but also turn off the hvac if it is on - await self.async_set_fan_mode(FAN_ON) - hvac_mode = HVACMode.OFF api_mode = THERMOSTAT_INV_MODE_MAP[hvac_mode] trait = self._device.traits[ThermostatModeTrait.NAME] try: @@ -338,6 +331,10 @@ class ThermostatEntity(ClimateEntity): """Set new target fan mode.""" if fan_mode not in self.fan_modes: raise ValueError(f"Unsupported fan_mode '{fan_mode}'") + if fan_mode == FAN_ON and self.hvac_mode == HVACMode.OFF: + raise ValueError( + "Cannot turn on fan, please set an HVAC mode (e.g. heat/cool) first" + ) trait = self._device.traits[FanTrait.NAME] duration = None if fan_mode != FAN_OFF: diff --git a/tests/components/nest/test_climate_sdm.py b/tests/components/nest/test_climate_sdm.py index 123742607ad..c271687a348 100644 --- a/tests/components/nest/test_climate_sdm.py +++ b/tests/components/nest/test_climate_sdm.py @@ -33,15 +33,15 @@ from homeassistant.components.climate.const import ( FAN_ON, HVAC_MODE_COOL, HVAC_MODE_DRY, - HVAC_MODE_FAN_ONLY, HVAC_MODE_HEAT, HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF, PRESET_ECO, PRESET_NONE, PRESET_SLEEP, + ClimateEntityFeature, ) -from homeassistant.const import ATTR_TEMPERATURE +from homeassistant.const import ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -794,7 +794,7 @@ async def test_thermostat_fan_off( "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, "sdm.devices.traits.ThermostatMode": { "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], - "mode": "OFF", + "mode": "COOL", }, "sdm.devices.traits.Temperature": { "ambientTemperatureCelsius": 16.2, @@ -806,18 +806,22 @@ async def test_thermostat_fan_off( assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") assert thermostat is not None - assert thermostat.state == HVAC_MODE_OFF - assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + assert thermostat.state == HVAC_MODE_COOL + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2 assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { HVAC_MODE_HEAT, HVAC_MODE_COOL, HVAC_MODE_HEAT_COOL, - HVAC_MODE_FAN_ONLY, HVAC_MODE_OFF, } assert thermostat.attributes[ATTR_FAN_MODE] == FAN_OFF assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] + assert thermostat.attributes[ATTR_SUPPORTED_FEATURES] == ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.FAN_MODE + ) async def test_thermostat_fan_on( @@ -837,7 +841,7 @@ async def test_thermostat_fan_on( }, "sdm.devices.traits.ThermostatMode": { "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], - "mode": "OFF", + "mode": "COOL", }, "sdm.devices.traits.Temperature": { "ambientTemperatureCelsius": 16.2, @@ -849,18 +853,22 @@ async def test_thermostat_fan_on( assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") assert thermostat is not None - assert thermostat.state == HVAC_MODE_FAN_ONLY + assert thermostat.state == HVAC_MODE_COOL assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2 assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { HVAC_MODE_HEAT, HVAC_MODE_COOL, HVAC_MODE_HEAT_COOL, - HVAC_MODE_FAN_ONLY, HVAC_MODE_OFF, } assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] + assert thermostat.attributes[ATTR_SUPPORTED_FEATURES] == ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.FAN_MODE + ) async def test_thermostat_cool_with_fan( @@ -895,11 +903,15 @@ async def test_thermostat_cool_with_fan( HVAC_MODE_HEAT, HVAC_MODE_COOL, HVAC_MODE_HEAT_COOL, - HVAC_MODE_FAN_ONLY, HVAC_MODE_OFF, } assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] + assert thermostat.attributes[ATTR_SUPPORTED_FEATURES] == ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.FAN_MODE + ) async def test_thermostat_set_fan( @@ -907,6 +919,68 @@ async def test_thermostat_set_fan( setup_platform: PlatformSetup, auth: FakeAuth, create_device: CreateDevice, +) -> None: + """Test a thermostat enabling the fan.""" + create_device.create( + { + "sdm.devices.traits.Fan": { + "timerMode": "ON", + "timerTimeout": "2019-05-10T03:22:54Z", + }, + "sdm.devices.traits.ThermostatHvac": { + "status": "OFF", + }, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "HEAT", + }, + } + ) + await setup_platform() + + assert len(hass.states.async_all()) == 1 + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVAC_MODE_HEAT + assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON + assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] + assert thermostat.attributes[ATTR_SUPPORTED_FEATURES] == ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.FAN_MODE + ) + + # Turn off fan mode + await common.async_set_fan_mode(hass, FAN_OFF) + await hass.async_block_till_done() + + assert auth.method == "post" + assert auth.url == DEVICE_COMMAND + assert auth.json == { + "command": "sdm.devices.commands.Fan.SetTimer", + "params": {"timerMode": "OFF"}, + } + + # Turn on fan mode + await common.async_set_fan_mode(hass, FAN_ON) + await hass.async_block_till_done() + + assert auth.method == "post" + assert auth.url == DEVICE_COMMAND + assert auth.json == { + "command": "sdm.devices.commands.Fan.SetTimer", + "params": { + "duration": "43200s", + "timerMode": "ON", + }, + } + + +async def test_thermostat_set_fan_when_off( + hass: HomeAssistant, + setup_platform: PlatformSetup, + auth: FakeAuth, + create_device: CreateDevice, ) -> None: """Test a thermostat enabling the fan.""" create_device.create( @@ -929,34 +1003,18 @@ async def test_thermostat_set_fan( assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") assert thermostat is not None - assert thermostat.state == HVAC_MODE_FAN_ONLY + assert thermostat.state == HVAC_MODE_OFF assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] + assert thermostat.attributes[ATTR_SUPPORTED_FEATURES] == ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.FAN_MODE + ) - # Turn off fan mode - await common.async_set_fan_mode(hass, FAN_OFF) - await hass.async_block_till_done() - - assert auth.method == "post" - assert auth.url == DEVICE_COMMAND - assert auth.json == { - "command": "sdm.devices.commands.Fan.SetTimer", - "params": {"timerMode": "OFF"}, - } - - # Turn on fan mode - await common.async_set_fan_mode(hass, FAN_ON) - await hass.async_block_till_done() - - assert auth.method == "post" - assert auth.url == DEVICE_COMMAND - assert auth.json == { - "command": "sdm.devices.commands.Fan.SetTimer", - "params": { - "duration": "43200s", - "timerMode": "ON", - }, - } + # Fan cannot be turned on when HVAC is off + with pytest.raises(ValueError): + await common.async_set_fan_mode(hass, FAN_ON, entity_id="climate.my_thermostat") async def test_thermostat_fan_empty( @@ -994,6 +1052,10 @@ async def test_thermostat_fan_empty( } assert ATTR_FAN_MODE not in thermostat.attributes assert ATTR_FAN_MODES not in thermostat.attributes + assert thermostat.attributes[ATTR_SUPPORTED_FEATURES] == ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) # Ignores set_fan_mode since it is lacking SUPPORT_FAN_MODE await common.async_set_fan_mode(hass, FAN_ON) @@ -1018,7 +1080,7 @@ async def test_thermostat_invalid_fan_mode( "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, "sdm.devices.traits.ThermostatMode": { "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], - "mode": "OFF", + "mode": "COOL", }, "sdm.devices.traits.Temperature": { "ambientTemperatureCelsius": 16.2, @@ -1030,14 +1092,13 @@ async def test_thermostat_invalid_fan_mode( assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") assert thermostat is not None - assert thermostat.state == HVAC_MODE_FAN_ONLY + assert thermostat.state == HVAC_MODE_COOL assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2 assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { HVAC_MODE_HEAT, HVAC_MODE_COOL, HVAC_MODE_HEAT_COOL, - HVAC_MODE_FAN_ONLY, HVAC_MODE_OFF, } assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON @@ -1048,58 +1109,6 @@ async def test_thermostat_invalid_fan_mode( await hass.async_block_till_done() -async def test_thermostat_set_hvac_fan_only( - hass: HomeAssistant, - setup_platform: PlatformSetup, - auth: FakeAuth, - create_device: CreateDevice, -) -> None: - """Test a thermostat enabling the fan via hvac_mode.""" - create_device.create( - { - "sdm.devices.traits.Fan": { - "timerMode": "OFF", - "timerTimeout": "2019-05-10T03:22:54Z", - }, - "sdm.devices.traits.ThermostatHvac": { - "status": "OFF", - }, - "sdm.devices.traits.ThermostatMode": { - "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], - "mode": "OFF", - }, - } - ) - await setup_platform() - - assert len(hass.states.async_all()) == 1 - thermostat = hass.states.get("climate.my_thermostat") - assert thermostat is not None - assert thermostat.state == HVAC_MODE_OFF - assert thermostat.attributes[ATTR_FAN_MODE] == FAN_OFF - assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] - - await common.async_set_hvac_mode(hass, HVAC_MODE_FAN_ONLY) - await hass.async_block_till_done() - - assert len(auth.captured_requests) == 2 - - (method, url, json, headers) = auth.captured_requests.pop(0) - assert method == "post" - assert url == DEVICE_COMMAND - assert json == { - "command": "sdm.devices.commands.Fan.SetTimer", - "params": {"duration": "43200s", "timerMode": "ON"}, - } - (method, url, json, headers) = auth.captured_requests.pop(0) - assert method == "post" - assert url == DEVICE_COMMAND - assert json == { - "command": "sdm.devices.commands.ThermostatMode.SetMode", - "params": {"mode": "OFF"}, - } - - async def test_thermostat_target_temp( hass: HomeAssistant, setup_platform: PlatformSetup, @@ -1397,7 +1406,7 @@ async def test_thermostat_hvac_mode_failure( "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, "sdm.devices.traits.ThermostatMode": { "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], - "mode": "OFF", + "mode": "COOL", }, "sdm.devices.traits.Fan": { "timerMode": "OFF", @@ -1416,8 +1425,8 @@ async def test_thermostat_hvac_mode_failure( assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") assert thermostat is not None - assert thermostat.state == HVAC_MODE_OFF - assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + assert thermostat.state == HVAC_MODE_COOL + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE auth.responses = [aiohttp.web.Response(status=HTTPStatus.BAD_REQUEST)] with pytest.raises(HomeAssistantError):