diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 49b5c5141b6..2802dfd3a48 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -9,9 +9,11 @@ from homeassistant.const import ( STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_CLOSED, STATE_LOCKED, STATE_OFF, STATE_ON, + STATE_OPEN, STATE_PAUSED, STATE_PLAYING, STATE_UNAVAILABLE, @@ -888,6 +890,9 @@ class AlexaModeController(AlexaCapability): if self.instance == f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}": return self.entity.attributes.get(fan.ATTR_DIRECTION) + if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + return self.entity.attributes.get(cover.ATTR_POSITION) + return None def configuration(self): @@ -903,6 +908,12 @@ class AlexaModeController(AlexaCapability): {"type": Catalog.LABEL_ASSET, "value": Catalog.SETTING_DIRECTION} ] + if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + capability_resources = [ + {"type": Catalog.LABEL_ASSET, "value": Catalog.SETTING_MODE}, + {"type": Catalog.LABEL_ASSET, "value": Catalog.SETTING_PRESET}, + ] + return capability_resources def mode_resources(self): @@ -927,6 +938,32 @@ class AlexaModeController(AlexaCapability): ], } + if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + mode_resources = { + "ordered": False, + "resources": [ + { + "value": f"{cover.ATTR_POSITION}.{STATE_OPEN}", + "friendly_names": [ + {"type": Catalog.LABEL_TEXT, "value": "open"}, + {"type": Catalog.LABEL_TEXT, "value": "opened"}, + {"type": Catalog.LABEL_TEXT, "value": "raise"}, + {"type": Catalog.LABEL_TEXT, "value": "raised"}, + ], + }, + { + "value": f"{cover.ATTR_POSITION}.{STATE_CLOSED}", + "friendly_names": [ + {"type": Catalog.LABEL_TEXT, "value": "close"}, + {"type": Catalog.LABEL_TEXT, "value": "closed"}, + {"type": Catalog.LABEL_TEXT, "value": "shut"}, + {"type": Catalog.LABEL_TEXT, "value": "lower"}, + {"type": Catalog.LABEL_TEXT, "value": "lowered"}, + ], + }, + ], + } + return mode_resources def serialize_mode_resources(self): diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index d3bfe1c5d8d..20376c51223 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -311,7 +311,10 @@ class CoverCapabilities(AlexaEntity): def default_display_categories(self): """Return the display categories for this entity.""" - return [DisplayCategory.DOOR] + device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS) + if device_class in (cover.DEVICE_CLASS_GARAGE, cover.DEVICE_CLASS_DOOR): + return [DisplayCategory.DOOR] + return [DisplayCategory.OTHER] def interfaces(self): """Yield the supported interfaces.""" @@ -319,6 +322,10 @@ class CoverCapabilities(AlexaEntity): supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if supported & cover.SUPPORT_SET_POSITION: yield AlexaPercentageController(self.entity) + if supported & (cover.SUPPORT_CLOSE | cover.SUPPORT_OPEN): + yield AlexaModeController( + self.entity, instance=f"{cover.DOMAIN}.{cover.ATTR_POSITION}" + ) yield AlexaEndpointHealth(self.hass, self.entity) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index c23e01f501f..eae8b4a5520 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -9,7 +9,6 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, - STATE_ALARM_DISARMED, SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_NIGHT, @@ -28,6 +27,9 @@ from homeassistant.const import ( SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, SERVICE_VOLUME_UP, + STATE_ALARM_DISARMED, + STATE_CLOSED, + STATE_OPEN, TEMP_CELSIUS, TEMP_FAHRENHEIT, ) @@ -956,23 +958,42 @@ async def async_api_set_mode(hass, config, directive, context): domain = entity.domain service = None data = {ATTR_ENTITY_ID: entity.entity_id} - mode = directive.payload["mode"] + capability_mode = directive.payload["mode"] - if domain != fan.DOMAIN: + if domain not in (fan.DOMAIN, cover.DOMAIN): msg = "Entity does not support directive" raise AlexaInvalidDirectiveError(msg) if instance == f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}": - mode, direction = mode.split(".") - if direction in [fan.DIRECTION_REVERSE, fan.DIRECTION_FORWARD]: + _, direction = capability_mode.split(".") + if direction in (fan.DIRECTION_REVERSE, fan.DIRECTION_FORWARD): service = fan.SERVICE_SET_DIRECTION data[fan.ATTR_DIRECTION] = direction + if instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + _, position = capability_mode.split(".") + + if position == STATE_CLOSED: + service = cover.SERVICE_CLOSE_COVER + + if position == STATE_OPEN: + service = cover.SERVICE_OPEN_COVER + await hass.services.async_call( domain, service, data, blocking=False, context=context ) - return directive.response() + response = directive.response() + response.add_context_property( + { + "namespace": "Alexa.ModeController", + "instance": instance, + "name": "mode", + "value": capability_mode, + } + ) + + return response @HANDLERS.register(("Alexa.ModeController", "AdjustMode")) diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 20cae0e10e4..7738df5cd78 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -565,7 +565,7 @@ async def test_direction_fan(hass): }, } in supported_modes - call, _ = await assert_request_calls_service( + call, msg = await assert_request_calls_service( "Alexa.ModeController", "SetMode", "fan#test_4", @@ -575,6 +575,25 @@ async def test_direction_fan(hass): instance="fan.direction", ) assert call.data["direction"] == "reverse" + properties = msg["context"]["properties"][0] + assert properties["name"] == "mode" + assert properties["namespace"] == "Alexa.ModeController" + assert properties["value"] == "direction.reverse" + + call, msg = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "fan#test_4", + "fan.set_direction", + hass, + payload={"mode": "direction.forward"}, + instance="fan.direction", + ) + assert call.data["direction"] == "forward" + properties = msg["context"]["properties"][0] + assert properties["name"] == "mode" + assert properties["namespace"] == "Alexa.ModeController" + assert properties["value"] == "direction.forward" # Test for AdjustMode instance=None Error coverage with pytest.raises(AssertionError): @@ -1190,11 +1209,12 @@ async def test_cover(hass): appliance = await discovery_test(device, hass) assert appliance["endpointId"] == "cover#test" - assert appliance["displayCategories"][0] == "DOOR" + assert appliance["displayCategories"][0] == "OTHER" assert appliance["friendlyName"] == "Test cover" assert_endpoint_capabilities( appliance, + "Alexa.ModeController", "Alexa.PercentageController", "Alexa.PowerController", "Alexa.EndpointHealth", @@ -2076,3 +2096,98 @@ async def test_mode_unsupported_domain(hass): assert msg["header"]["name"] == "ErrorResponse" assert msg["header"]["namespace"] == "Alexa" assert msg["payload"]["type"] == "INVALID_DIRECTIVE" + + +async def test_cover_position(hass): + """Test cover position mode discovery.""" + device = ( + "cover.test", + "off", + {"friendly_name": "Test cover", "supported_features": 255, "position": 30}, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "cover#test" + assert appliance["displayCategories"][0] == "OTHER" + assert appliance["friendlyName"] == "Test cover" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.ModeController", + "Alexa.PercentageController", + "Alexa.PowerController", + "Alexa.EndpointHealth", + ) + + mode_capability = get_capability(capabilities, "Alexa.ModeController") + assert mode_capability is not None + assert mode_capability["instance"] == "cover.position" + + properties = mode_capability["properties"] + assert properties["nonControllable"] is False + assert {"name": "mode"} in properties["supported"] + + capability_resources = mode_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "asset", + "value": {"assetId": "Alexa.Setting.Mode"}, + } in capability_resources["friendlyNames"] + + configuration = mode_capability["configuration"] + assert configuration is not None + assert configuration["ordered"] is False + + supported_modes = configuration["supportedModes"] + assert supported_modes is not None + assert { + "value": "position.open", + "modeResources": { + "friendlyNames": [ + {"@type": "text", "value": {"text": "open", "locale": "en-US"}}, + {"@type": "text", "value": {"text": "opened", "locale": "en-US"}}, + {"@type": "text", "value": {"text": "raise", "locale": "en-US"}}, + {"@type": "text", "value": {"text": "raised", "locale": "en-US"}}, + ] + }, + } in supported_modes + assert { + "value": "position.closed", + "modeResources": { + "friendlyNames": [ + {"@type": "text", "value": {"text": "close", "locale": "en-US"}}, + {"@type": "text", "value": {"text": "closed", "locale": "en-US"}}, + {"@type": "text", "value": {"text": "shut", "locale": "en-US"}}, + {"@type": "text", "value": {"text": "lower", "locale": "en-US"}}, + {"@type": "text", "value": {"text": "lowered", "locale": "en-US"}}, + ] + }, + } in supported_modes + + call, msg = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "cover#test", + "cover.close_cover", + hass, + payload={"mode": "position.closed"}, + instance="cover.position", + ) + properties = msg["context"]["properties"][0] + assert properties["name"] == "mode" + assert properties["namespace"] == "Alexa.ModeController" + assert properties["value"] == "position.closed" + + call, msg = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "cover#test", + "cover.open_cover", + hass, + payload={"mode": "position.open"}, + instance="cover.position", + ) + properties = msg["context"]["properties"][0] + assert properties["name"] == "mode" + assert properties["namespace"] == "Alexa.ModeController" + assert properties["value"] == "position.open"