diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 549dc1b23418..1ddc7654d62b 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -71,6 +71,7 @@ from .media_source import ( async_get_media_source_devices, async_get_transcoder, ) +from .services import async_setup_services from .types import DevicesAddedListener, NestConfigEntry, NestData _LOGGER = logging.getLogger(__name__) @@ -115,6 +116,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Nest components with dispatch between old/new flows.""" hass.http.register_view(NestEventMediaView(hass)) hass.http.register_view(NestEventMediaThumbnailView(hass)) + async_setup_services(hass) return True diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py index b2ca81563f7f..ea4073f59685 100644 --- a/homeassistant/components/nest/climate.py +++ b/homeassistant/components/nest/climate.py @@ -1,5 +1,6 @@ """Support for Google Nest SDM climate devices.""" +from datetime import timedelta from typing import Any, cast from google_nest_sdm.device import Device @@ -67,7 +68,7 @@ FAN_MODE_MAP = { 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 +MAX_FAN_DURATION = 43200 # 12 hours is the max in the SDM API MIN_TEMP = 10 MAX_TEMP = 32 MIN_TEMP_RANGE = 1.66667 @@ -344,3 +345,27 @@ class ThermostatEntity(ClimateEntity): raise HomeAssistantError( f"Error setting {self.entity_id} fan mode to {fan_mode}: {err}" ) from err + + async def async_set_fan_timer(self, duration: timedelta) -> None: + """Set a short term fan timer.""" + if not self.supported_features & ClimateEntityFeature.FAN_MODE: + raise HomeAssistantError(f"Entity {self.entity_id} does not support fan") + + if self.hvac_mode == HVACMode.OFF: + raise HomeAssistantError( + f"Cannot turn on fan for {self.entity_id}, please set an HVAC mode (e.g. heat/cool) first" + ) + + seconds = int(duration.total_seconds()) + if seconds <= 0 or seconds > MAX_FAN_DURATION: + raise ValueError( + f"Duration {seconds} for {self.entity_id} must be between 1 and {MAX_FAN_DURATION} seconds" + ) + + trait = self._device.traits[FanTrait.NAME] + try: + await trait.set_timer(FAN_INV_MODE_MAP[FAN_ON], duration=seconds) + except ApiException as err: + raise HomeAssistantError( + f"Error setting {self.entity_id} fan timer: {err}" + ) from err diff --git a/homeassistant/components/nest/icons.json b/homeassistant/components/nest/icons.json new file mode 100644 index 000000000000..1147328d12a4 --- /dev/null +++ b/homeassistant/components/nest/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "set_fan_timer": { + "service": "mdi:fan-clock" + } + } +} diff --git a/homeassistant/components/nest/services.py b/homeassistant/components/nest/services.py new file mode 100644 index 000000000000..0cb97f5a0a54 --- /dev/null +++ b/homeassistant/components/nest/services.py @@ -0,0 +1,30 @@ +"""Define services for the Nest integration.""" + +import voluptuous as vol + +from homeassistant.components.climate import ( + DOMAIN as CLIMATE_DOMAIN, + ClimateEntityFeature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, service + +from .const import DOMAIN + +SERVICE_SET_FAN_TIMER = "set_fan_timer" + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register services for the Nest integration.""" + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_SET_FAN_TIMER, + entity_domain=CLIMATE_DOMAIN, + schema={ + vol.Required("duration"): cv.time_period, + }, + func="async_set_fan_timer", + required_features=[ClimateEntityFeature.FAN_MODE], + ) diff --git a/homeassistant/components/nest/services.yaml b/homeassistant/components/nest/services.yaml new file mode 100644 index 000000000000..820ed5e17792 --- /dev/null +++ b/homeassistant/components/nest/services.yaml @@ -0,0 +1,12 @@ +set_fan_timer: + target: + entity: + domain: climate + integration: nest + supported_features: + - climate.ClimateEntityFeature.FAN_MODE + fields: + duration: + required: true + selector: + duration: diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index aa4490d03f92..6ae0d6622b54 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -163,5 +163,17 @@ "create_new_topic": "Create new topic" } } + }, + "services": { + "set_fan_timer": { + "description": "Sets the fan to run for a specific duration.", + "fields": { + "duration": { + "description": "The duration the fan should run for.", + "name": "Duration" + } + }, + "name": "Set fan timer" + } } } diff --git a/tests/components/nest/test_climate.py b/tests/components/nest/test_climate.py index 76a9a52f2de2..e45fc5752d72 100644 --- a/tests/components/nest/test_climate.py +++ b/tests/components/nest/test_climate.py @@ -39,11 +39,16 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import ( + HomeAssistantError, + ServiceNotSupported, + ServiceValidationError, +) from .common import ( DEVICE_COMMAND, DEVICE_ID, + DOMAIN, CreateDevice, PlatformSetup, create_nest_event, @@ -1085,6 +1090,191 @@ async def test_thermostat_set_fan( } +async def test_set_fan_timer( + hass: HomeAssistant, + setup_platform: PlatformSetup, + auth: FakeAuth, + create_device: CreateDevice, +) -> None: + """Test the set_fan_timer service.""" + create_device.create( + { + "sdm.devices.traits.Fan": { + "timerMode": "OFF", + }, + "sdm.devices.traits.ThermostatHvac": {"status": "HEATING"}, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "OFF"], + "mode": "HEAT", + }, + "sdm.devices.traits.TemperatureTrait": {"ambientTemperatureCelsius": 20.0}, + } + ) + await setup_platform() + + # Call the set_fan_timer service + await hass.services.async_call( + DOMAIN, + "set_fan_timer", + { + "entity_id": "climate.my_thermostat", + "duration": {"minutes": 15}, + }, + blocking=True, + ) + + assert auth.method == "post" + assert auth.url == DEVICE_COMMAND + assert auth.json == { + "command": "sdm.devices.commands.Fan.SetTimer", + "params": { + "duration": "900s", + "timerMode": "ON", + }, + } + + +async def test_set_fan_timer_hvac_off( + hass: HomeAssistant, + setup_platform: PlatformSetup, + auth: FakeAuth, + create_device: CreateDevice, +) -> None: + """Test the set_fan_timer service when HVAC is off.""" + create_device.create( + { + "sdm.devices.traits.Fan": { + "timerMode": "OFF", + }, + "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "OFF"], + "mode": "OFF", + }, + } + ) + await setup_platform() + + with pytest.raises(HomeAssistantError, match="Cannot turn on fan"): + await hass.services.async_call( + DOMAIN, + "set_fan_timer", + { + "entity_id": "climate.my_thermostat", + "duration": {"minutes": 15}, + }, + blocking=True, + ) + + +async def test_set_fan_timer_no_fan( + hass: HomeAssistant, + setup_platform: PlatformSetup, + create_device: CreateDevice, +) -> None: + """Test the set_fan_timer service when device does not support fan.""" + create_device.create( + { + "sdm.devices.traits.ThermostatHvac": {"status": "HEATING"}, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "OFF"], + "mode": "HEAT", + }, + } + ) + await setup_platform() + + with pytest.raises(ServiceNotSupported, match="does not support action"): + await hass.services.async_call( + DOMAIN, + "set_fan_timer", + { + "entity_id": "climate.my_thermostat", + "duration": {"minutes": 15}, + }, + blocking=True, + ) + + +async def test_set_fan_timer_api_error( + hass: HomeAssistant, + setup_platform: PlatformSetup, + auth: FakeAuth, + create_device: CreateDevice, +) -> None: + """Test the set_fan_timer service with an API error.""" + create_device.create( + { + "sdm.devices.traits.Fan": { + "timerMode": "OFF", + }, + "sdm.devices.traits.ThermostatHvac": {"status": "HEATING"}, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "OFF"], + "mode": "HEAT", + }, + } + ) + await setup_platform() + + auth.responses = [aiohttp.web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR)] + with pytest.raises(HomeAssistantError, match="Error setting .* fan timer"): + await hass.services.async_call( + DOMAIN, + "set_fan_timer", + { + "entity_id": "climate.my_thermostat", + "duration": {"minutes": 15}, + }, + blocking=True, + ) + + +async def test_set_fan_timer_invalid_duration( + hass: HomeAssistant, + setup_platform: PlatformSetup, + create_device: CreateDevice, +) -> None: + """Test the set_fan_timer service with invalid durations.""" + create_device.create( + { + "sdm.devices.traits.Fan": { + "timerMode": "OFF", + }, + "sdm.devices.traits.ThermostatHvac": {"status": "HEATING"}, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "OFF"], + "mode": "HEAT", + }, + } + ) + await setup_platform() + + # Duration too long (over 12 hours) + with pytest.raises(ValueError, match="must be between 1 and 43200 seconds"): + await hass.services.async_call( + DOMAIN, + "set_fan_timer", + { + "entity_id": "climate.my_thermostat", + "duration": {"hours": 16}, + }, + blocking=True, + ) + + # Duration zero + with pytest.raises(ValueError, match="must be between 1 and 43200 seconds"): + await hass.services.async_call( + DOMAIN, + "set_fan_timer", + { + "entity_id": "climate.my_thermostat", + "duration": {"seconds": 0}, + }, + blocking=True, + ) + + async def test_thermostat_set_fan_when_off( hass: HomeAssistant, setup_platform: PlatformSetup,