diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index d1b7917f263..b13cfd7d370 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -1,7 +1,14 @@ """Alexa capabilities.""" import logging -from homeassistant.components import cover, fan, image_processing, input_number, light +from homeassistant.components import ( + cover, + fan, + image_processing, + input_number, + light, + vacuum, +) from homeassistant.components.alarm_control_panel import ATTR_CODE_FORMAT, FORMAT_NUMBER import homeassistant.components.climate.const as climate import homeassistant.components.media_player.const as media_player @@ -1291,6 +1298,15 @@ class AlexaRangeController(AlexaCapability): if self.instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}": return float(self.entity.state) + # Vacuum Fan Speed + if self.instance == f"{vacuum.DOMAIN}.{vacuum.ATTR_FAN_SPEED}": + speed_list = self.entity.attributes[vacuum.ATTR_FAN_SPEED_LIST] + speed = self.entity.attributes[vacuum.ATTR_FAN_SPEED] + speed_index = next( + (i for i, v in enumerate(speed_list) if v == speed), None + ) + return speed_index + return None def configuration(self): @@ -1367,6 +1383,26 @@ class AlexaRangeController(AlexaCapability): ) return self._resource.serialize_capability_resources() + # Vacuum Fan Speed Resources + if self.instance == f"{vacuum.DOMAIN}.{vacuum.ATTR_FAN_SPEED}": + speed_list = self.entity.attributes[vacuum.ATTR_FAN_SPEED_LIST] + max_value = len(speed_list) - 1 + self._resource = AlexaPresetResource( + labels=[AlexaGlobalCatalog.SETTING_FAN_SPEED], + min_value=0, + max_value=max_value, + precision=1, + ) + for index, speed in enumerate(speed_list): + labels = [speed.replace("_", " ")] + if index == 1: + labels.append(AlexaGlobalCatalog.VALUE_MINIMUM) + if index == max_value: + labels.append(AlexaGlobalCatalog.VALUE_MAXIMUM) + self._resource.add_preset(value=index, labels=labels) + + return self._resource.serialize_capability_resources() + return None def semantics(self): diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 6d1997589a4..2a326b2e367 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -20,6 +20,7 @@ from homeassistant.components import ( sensor, switch, timer, + vacuum, ) from homeassistant.components.climate import const as climate from homeassistant.const import ( @@ -724,3 +725,34 @@ class TimerCapabilities(AlexaEntity): """Yield the supported interfaces.""" yield AlexaTimeHoldController(self.entity, allow_remote_resume=True) yield Alexa(self.entity) + + +@ENTITY_ADAPTERS.register(vacuum.DOMAIN) +class VacuumCapabilities(AlexaEntity): + """Class to represent vacuum capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.OTHER] + + def interfaces(self): + """Yield the supported interfaces.""" + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if (supported & vacuum.SUPPORT_TURN_ON) and ( + supported & vacuum.SUPPORT_TURN_OFF + ): + yield AlexaPowerController(self.entity) + + if supported & vacuum.SUPPORT_FAN_SPEED: + yield AlexaRangeController( + self.entity, instance=f"{vacuum.DOMAIN}.{vacuum.ATTR_FAN_SPEED}" + ) + + if supported & vacuum.SUPPORT_PAUSE: + support_resume = bool(supported & vacuum.SUPPORT_START) + yield AlexaTimeHoldController( + self.entity, allow_remote_resume=support_resume + ) + + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.hass) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 510efe4b610..701b614aaa0 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -11,6 +11,7 @@ from homeassistant.components import ( light, media_player, timer, + vacuum, ) from homeassistant.components.climate import const as climate from homeassistant.const import ( @@ -1138,6 +1139,20 @@ async def async_api_set_range(hass, config, directive, context): max_value = float(entity.attributes[input_number.ATTR_MAX]) data[input_number.ATTR_VALUE] = min(max_value, max(min_value, range_value)) + # Vacuum Fan Speed + elif instance == f"{vacuum.DOMAIN}.{vacuum.ATTR_FAN_SPEED}": + service = vacuum.SERVICE_SET_FAN_SPEED + speed_list = entity.attributes[vacuum.ATTR_FAN_SPEED_LIST] + speed = next( + (v for i, v in enumerate(speed_list) if i == int(range_value)), None + ) + + if not speed: + msg = "Entity does not support value" + raise AlexaInvalidValueError(msg) + + data[vacuum.ATTR_FAN_SPEED] = speed + else: msg = "Entity does not support directive" raise AlexaInvalidDirectiveError(msg) @@ -1220,6 +1235,24 @@ async def async_api_adjust_range(hass, config, directive, context): max_value, max(min_value, range_delta + current) ) + # Vacuum Fan Speed + elif instance == f"{vacuum.DOMAIN}.{vacuum.ATTR_FAN_SPEED}": + range_delta = int(range_delta) + service = vacuum.SERVICE_SET_FAN_SPEED + speed_list = entity.attributes[vacuum.ATTR_FAN_SPEED_LIST] + current_speed = entity.attributes[vacuum.ATTR_FAN_SPEED] + current_speed_index = next( + (i for i, v in enumerate(speed_list) if v == current_speed), 0 + ) + new_speed_index = min( + len(speed_list) - 1, max(0, current_speed_index + range_delta) + ) + speed = next( + (v for i, v in enumerate(speed_list) if i == new_speed_index), None + ) + + data[vacuum.ATTR_FAN_SPEED] = response_value = speed + else: msg = "Entity does not support directive" raise AlexaInvalidDirectiveError(msg) @@ -1412,8 +1445,18 @@ async def async_api_hold(hass, config, directive, context): entity = directive.entity data = {ATTR_ENTITY_ID: entity.entity_id} + if entity.domain == timer.DOMAIN: + service = timer.SERVICE_PAUSE + + elif entity.domain == vacuum.DOMAIN: + service = vacuum.SERVICE_START_PAUSE + + else: + msg = "Entity does not support directive" + raise AlexaInvalidDirectiveError(msg) + await hass.services.async_call( - entity.domain, timer.SERVICE_PAUSE, data, blocking=False, context=context + entity.domain, service, data, blocking=False, context=context ) return directive.response() @@ -1425,8 +1468,18 @@ async def async_api_resume(hass, config, directive, context): entity = directive.entity data = {ATTR_ENTITY_ID: entity.entity_id} + if entity.domain == timer.DOMAIN: + service = timer.SERVICE_START + + elif entity.domain == vacuum.DOMAIN: + service = vacuum.SERVICE_START_PAUSE + + else: + msg = "Entity does not support directive" + raise AlexaInvalidDirectiveError(msg) + await hass.services.async_call( - entity.domain, timer.SERVICE_START, data, blocking=False, context=context + entity.domain, service, data, blocking=False, context=context ) return directive.response() diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index dd6faab8e96..ceedebcaec4 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -17,6 +17,7 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, ) +import homeassistant.components.vacuum as vacuum from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.core import Context, callback from homeassistant.helpers import entityfilter @@ -3134,3 +3135,223 @@ async def test_timer_resume(hass): await assert_request_calls_service( "Alexa.TimeHoldController", "Resume", "timer#laundry", "timer.start", hass ) + + +async def test_vacuum_discovery(hass): + """Test vacuum discovery.""" + device = ( + "vacuum.test_1", + "docked", + { + "friendly_name": "Test vacuum 1", + "supported_features": vacuum.SUPPORT_TURN_ON + | vacuum.SUPPORT_TURN_OFF + | vacuum.SUPPORT_START + | vacuum.SUPPORT_STOP + | vacuum.SUPPORT_PAUSE, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "vacuum#test_1" + assert appliance["displayCategories"][0] == "OTHER" + assert appliance["friendlyName"] == "Test vacuum 1" + + assert_endpoint_capabilities( + appliance, + "Alexa.PowerController", + "Alexa.TimeHoldController", + "Alexa.EndpointHealth", + "Alexa", + ) + + +async def test_vacuum_fan_speed(hass): + """Test vacuum fan speed with rangeController.""" + device = ( + "vacuum.test_2", + "cleaning", + { + "friendly_name": "Test vacuum 2", + "supported_features": vacuum.SUPPORT_TURN_ON + | vacuum.SUPPORT_TURN_OFF + | vacuum.SUPPORT_START + | vacuum.SUPPORT_STOP + | vacuum.SUPPORT_PAUSE + | vacuum.SUPPORT_FAN_SPEED, + "fan_speed_list": ["off", "low", "medium", "high", "turbo", "super_sucker"], + "fan_speed": "medium", + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "vacuum#test_2" + assert appliance["displayCategories"][0] == "OTHER" + assert appliance["friendlyName"] == "Test vacuum 2" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PowerController", + "Alexa.RangeController", + "Alexa.TimeHoldController", + "Alexa.EndpointHealth", + "Alexa", + ) + + range_capability = get_capability(capabilities, "Alexa.RangeController") + assert range_capability is not None + assert range_capability["instance"] == "vacuum.fan_speed" + + capability_resources = range_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "asset", + "value": {"assetId": "Alexa.Setting.FanSpeed"}, + } in capability_resources["friendlyNames"] + + configuration = range_capability["configuration"] + assert configuration is not None + + supported_range = configuration["supportedRange"] + assert supported_range["minimumValue"] == 0 + assert supported_range["maximumValue"] == 5 + assert supported_range["precision"] == 1 + + presets = configuration["presets"] + assert { + "rangeValue": 0, + "presetResources": { + "friendlyNames": [ + {"@type": "text", "value": {"text": "off", "locale": "en-US"}} + ] + }, + } in presets + + assert { + "rangeValue": 1, + "presetResources": { + "friendlyNames": [ + {"@type": "text", "value": {"text": "low", "locale": "en-US"}}, + {"@type": "asset", "value": {"assetId": "Alexa.Value.Minimum"}}, + ] + }, + } in presets + + assert { + "rangeValue": 2, + "presetResources": { + "friendlyNames": [ + {"@type": "text", "value": {"text": "medium", "locale": "en-US"}} + ] + }, + } in presets + + assert { + "rangeValue": 5, + "presetResources": { + "friendlyNames": [ + {"@type": "text", "value": {"text": "super sucker", "locale": "en-US"}}, + {"@type": "asset", "value": {"assetId": "Alexa.Value.Maximum"}}, + ] + }, + } in presets + + call, _ = await assert_request_calls_service( + "Alexa.RangeController", + "SetRangeValue", + "vacuum#test_2", + "vacuum.set_fan_speed", + hass, + payload={"rangeValue": "1"}, + instance="vacuum.fan_speed", + ) + assert call.data["fan_speed"] == "low" + + call, _ = await assert_request_calls_service( + "Alexa.RangeController", + "SetRangeValue", + "vacuum#test_2", + "vacuum.set_fan_speed", + hass, + payload={"rangeValue": "5"}, + instance="vacuum.fan_speed", + ) + assert call.data["fan_speed"] == "super_sucker" + + await assert_range_changes( + hass, + [("low", "-1"), ("high", "1"), ("medium", "0"), ("super_sucker", "99")], + "Alexa.RangeController", + "AdjustRangeValue", + "vacuum#test_2", + False, + "vacuum.set_fan_speed", + "fan_speed", + instance="vacuum.fan_speed", + ) + + +async def test_vacuum_pause(hass): + """Test vacuum pause with TimeHoldController.""" + device = ( + "vacuum.test_3", + "cleaning", + { + "friendly_name": "Test vacuum 3", + "supported_features": vacuum.SUPPORT_TURN_ON + | vacuum.SUPPORT_TURN_OFF + | vacuum.SUPPORT_START + | vacuum.SUPPORT_STOP + | vacuum.SUPPORT_PAUSE + | vacuum.SUPPORT_FAN_SPEED, + "fan_speed_list": ["off", "low", "medium", "high", "turbo", "super_sucker"], + "fan_speed": "medium", + }, + ) + appliance = await discovery_test(device, hass) + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PowerController", + "Alexa.RangeController", + "Alexa.TimeHoldController", + "Alexa.EndpointHealth", + "Alexa", + ) + + time_hold_capability = get_capability(capabilities, "Alexa.TimeHoldController") + assert time_hold_capability is not None + configuration = time_hold_capability["configuration"] + assert configuration["allowRemoteResume"] is True + + await assert_request_calls_service( + "Alexa.TimeHoldController", "Hold", "vacuum#test_3", "vacuum.start_pause", hass + ) + + +async def test_vacuum_resume(hass): + """Test vacuum resume with TimeHoldController.""" + device = ( + "vacuum.test_4", + "docked", + { + "friendly_name": "Test vacuum 4", + "supported_features": vacuum.SUPPORT_TURN_ON + | vacuum.SUPPORT_TURN_OFF + | vacuum.SUPPORT_START + | vacuum.SUPPORT_STOP + | vacuum.SUPPORT_PAUSE + | vacuum.SUPPORT_FAN_SPEED, + "fan_speed_list": ["off", "low", "medium", "high", "turbo", "super_sucker"], + "fan_speed": "medium", + }, + ) + await discovery_test(device, hass) + + await assert_request_calls_service( + "Alexa.TimeHoldController", + "Resume", + "vacuum#test_4", + "vacuum.start_pause", + hass, + )