diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 26d07760747..1dddc815d01 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -1073,6 +1073,15 @@ class AlexaSecurityPanelController(AlexaCapability): class AlexaModeController(AlexaCapability): """Implements Alexa.ModeController. + The instance property must be unique across ModeController, RangeController, ToggleController within the same device. + The instance property should be a concatenated string of device domain period and single word. + e.g. fan.speed & fan.direction. + + The instance property must not contain words from other instance property strings within the same device. + e.g. Instance property cover.position & cover.tilt_position will cause the Alexa.Discovery directive to fail. + + An instance property string value may be reused for different devices. + https://developer.amazon.com/docs/device-apis/alexa-modecontroller.html """ @@ -1183,28 +1192,38 @@ class AlexaModeController(AlexaCapability): def semantics(self): """Build and return semantics object.""" + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) # Cover Position if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + lower_labels = [AlexaSemantics.ACTION_LOWER] + raise_labels = [AlexaSemantics.ACTION_RAISE] self._semantics = AlexaSemantics() + + # Add open/close semantics if tilt is not supported. + if not supported & cover.SUPPORT_SET_TILT_POSITION: + lower_labels.append(AlexaSemantics.ACTION_CLOSE) + raise_labels.append(AlexaSemantics.ACTION_OPEN) + self._semantics.add_states_to_value( + [AlexaSemantics.STATES_CLOSED], + f"{cover.ATTR_POSITION}.{cover.STATE_CLOSED}", + ) + self._semantics.add_states_to_value( + [AlexaSemantics.STATES_OPEN], + f"{cover.ATTR_POSITION}.{cover.STATE_OPEN}", + ) + self._semantics.add_action_to_directive( - [AlexaSemantics.ACTION_CLOSE, AlexaSemantics.ACTION_LOWER], + lower_labels, "SetMode", {"mode": f"{cover.ATTR_POSITION}.{cover.STATE_CLOSED}"}, ) self._semantics.add_action_to_directive( - [AlexaSemantics.ACTION_OPEN, AlexaSemantics.ACTION_RAISE], + raise_labels, "SetMode", {"mode": f"{cover.ATTR_POSITION}.{cover.STATE_OPEN}"}, ) - self._semantics.add_states_to_value( - [AlexaSemantics.STATES_CLOSED], - f"{cover.ATTR_POSITION}.{cover.STATE_CLOSED}", - ) - self._semantics.add_states_to_value( - [AlexaSemantics.STATES_OPEN], - f"{cover.ATTR_POSITION}.{cover.STATE_OPEN}", - ) + return self._semantics.serialize_semantics() return None @@ -1213,6 +1232,15 @@ class AlexaModeController(AlexaCapability): class AlexaRangeController(AlexaCapability): """Implements Alexa.RangeController. + The instance property must be unique across ModeController, RangeController, ToggleController within the same device. + The instance property should be a concatenated string of device domain period and single word. + e.g. fan.speed & fan.direction. + + The instance property must not contain words from other instance property strings within the same device. + e.g. Instance property cover.position & cover.tilt_position will cause the Alexa.Discovery directive to fail. + + An instance property string value may be reused for different devices. + https://developer.amazon.com/docs/device-apis/alexa-rangecontroller.html """ @@ -1268,8 +1296,8 @@ class AlexaRangeController(AlexaCapability): if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": return self.entity.attributes.get(cover.ATTR_CURRENT_POSITION) - # Cover Tilt Position - if self.instance == f"{cover.DOMAIN}.{cover.ATTR_TILT_POSITION}": + # Cover Tilt + if self.instance == f"{cover.DOMAIN}.tilt": return self.entity.attributes.get(cover.ATTR_CURRENT_TILT_POSITION) # Input Number Value @@ -1321,10 +1349,10 @@ class AlexaRangeController(AlexaCapability): ) return self._resource.serialize_capability_resources() - # Cover Tilt Position Resources - if self.instance == f"{cover.DOMAIN}.{cover.ATTR_TILT_POSITION}": + # Cover Tilt Resources + if self.instance == f"{cover.DOMAIN}.tilt": self._resource = AlexaPresetResource( - ["Tilt Position", AlexaGlobalCatalog.SETTING_OPENING], + ["Tilt", "Angle", AlexaGlobalCatalog.SETTING_DIRECTION], min_value=0, max_value=100, precision=1, @@ -1358,24 +1386,35 @@ class AlexaRangeController(AlexaCapability): def semantics(self): """Build and return semantics object.""" + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) # Cover Position if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + lower_labels = [AlexaSemantics.ACTION_LOWER] + raise_labels = [AlexaSemantics.ACTION_RAISE] self._semantics = AlexaSemantics() + + # Add open/close semantics if tilt is not supported. + if not supported & cover.SUPPORT_SET_TILT_POSITION: + lower_labels.append(AlexaSemantics.ACTION_CLOSE) + raise_labels.append(AlexaSemantics.ACTION_OPEN) + self._semantics.add_states_to_value( + [AlexaSemantics.STATES_CLOSED], value=0 + ) + self._semantics.add_states_to_range( + [AlexaSemantics.STATES_OPEN], min_value=1, max_value=100 + ) + self._semantics.add_action_to_directive( - [AlexaSemantics.ACTION_LOWER], "SetRangeValue", {"rangeValue": 0} + lower_labels, "SetRangeValue", {"rangeValue": 0} ) self._semantics.add_action_to_directive( - [AlexaSemantics.ACTION_RAISE], "SetRangeValue", {"rangeValue": 100} - ) - self._semantics.add_states_to_value([AlexaSemantics.STATES_CLOSED], value=0) - self._semantics.add_states_to_range( - [AlexaSemantics.STATES_OPEN], min_value=1, max_value=100 + raise_labels, "SetRangeValue", {"rangeValue": 100} ) return self._semantics.serialize_semantics() - # Cover Tilt Position - if self.instance == f"{cover.DOMAIN}.{cover.ATTR_TILT_POSITION}": + # Cover Tilt + if self.instance == f"{cover.DOMAIN}.tilt": self._semantics = AlexaSemantics() self._semantics.add_action_to_directive( [AlexaSemantics.ACTION_CLOSE], "SetRangeValue", {"rangeValue": 0} @@ -1395,6 +1434,15 @@ class AlexaRangeController(AlexaCapability): class AlexaToggleController(AlexaCapability): """Implements Alexa.ToggleController. + The instance property must be unique across ModeController, RangeController, ToggleController within the same device. + The instance property should be a concatenated string of device domain period and single word. + e.g. fan.speed & fan.direction. + + The instance property must not contain words from other instance property strings within the same device. + e.g. Instance property cover.position & cover.tilt_position will cause the Alexa.Discovery directive to fail. + + An instance property string value may be reused for different devices. + https://developer.amazon.com/docs/device-apis/alexa-togglecontroller.html """ diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index d6fa0415640..084231f0090 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -404,9 +404,7 @@ class CoverCapabilities(AlexaEntity): self.entity, instance=f"{cover.DOMAIN}.{cover.ATTR_POSITION}" ) if supported & cover.SUPPORT_SET_TILT_POSITION: - yield AlexaRangeController( - self.entity, instance=f"{cover.DOMAIN}.{cover.ATTR_TILT_POSITION}" - ) + yield AlexaRangeController(self.entity, instance=f"{cover.DOMAIN}.tilt") 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 74c1b24d42b..1cb8980b0b1 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -1118,8 +1118,8 @@ async def async_api_set_range(hass, config, directive, context): service = cover.SERVICE_SET_COVER_POSITION data[cover.ATTR_POSITION] = range_value - # Cover Tilt Position - elif instance == f"{cover.DOMAIN}.{cover.ATTR_TILT_POSITION}": + # Cover Tilt + elif instance == f"{cover.DOMAIN}.tilt": range_value = int(range_value) if range_value == 0: service = cover.SERVICE_CLOSE_COVER_TILT @@ -1192,8 +1192,8 @@ async def async_api_adjust_range(hass, config, directive, context): 100, max(0, range_delta + current) ) - # Cover Tilt Position - elif instance == f"{cover.DOMAIN}.{cover.ATTR_TILT_POSITION}": + # Cover Tilt + elif instance == f"{cover.DOMAIN}.tilt": range_delta = int(range_delta) service = SERVICE_SET_COVER_TILT_POSITION current = entity.attributes.get(cover.ATTR_TILT_POSITION) diff --git a/homeassistant/components/alexa/resources.py b/homeassistant/components/alexa/resources.py index 09927321c36..d2580f3bfea 100644 --- a/homeassistant/components/alexa/resources.py +++ b/homeassistant/components/alexa/resources.py @@ -190,7 +190,12 @@ class AlexaGlobalCatalog: class AlexaCapabilityResource: - """Base class for Alexa capabilityResources, ModeResources, and presetResources objects. + """Base class for Alexa capabilityResources, modeResources, and presetResources objects. + + Resources objects labels must be unique across all modeResources and presetResources within the same device. + To provide support for all supported locales, include one label from the AlexaGlobalCatalog in the labels array. + You cannot use any words from the following list as friendly names: + https://developer.amazon.com/docs/alexa/device-apis/resources-and-assets.html#names-you-cannot-use https://developer.amazon.com/docs/device-apis/resources-and-assets.html#capability-resources """ @@ -312,6 +317,14 @@ class AlexaSemantics: Semantics is supported for the following interfaces only: ModeController, RangeController, and ToggleController. + Semantics stateMappings are only supported for one interface of the same type on the same device. If a device has + multiple RangeControllers only one interface may use stateMappings otherwise discovery will fail. + + You can support semantics actionMappings on different controllers for the same device, however each controller must + support different phrases. For example, you can support "raise" on a RangeController, and "open" on a ModeController, + but you can't support "open" on both RangeController and ModeController. Semantics stateMappings are only supported + for one interface on the same device. + https://developer.amazon.com/docs/device-apis/alexa-discovery.html#semantics-object """ diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 37301c3555e..51b1ed83982 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -132,7 +132,7 @@ def get_capability(capabilities, capability_name, instance=None): for capability in capabilities: if instance and capability["instance"] == instance: return capability - elif capability["interface"] == capability_name: + if not instance and capability["interface"] == capability_name: return capability return None @@ -1427,6 +1427,36 @@ async def test_cover_position_range(hass): assert supported_range["maximumValue"] == 100 assert supported_range["precision"] == 1 + # Assert for Position Semantics + position_semantics = range_capability["semantics"] + assert position_semantics is not None + + position_action_mappings = position_semantics["actionMappings"] + assert position_action_mappings is not None + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Lower", "Alexa.Actions.Close"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 0}}, + } in position_action_mappings + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Raise", "Alexa.Actions.Open"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 100}}, + } in position_action_mappings + + position_state_mappings = position_semantics["stateMappings"] + assert position_state_mappings is not None + assert { + "@type": "StatesToValue", + "states": ["Alexa.States.Closed"], + "value": 0, + } in position_state_mappings + assert { + "@type": "StatesToRange", + "states": ["Alexa.States.Open"], + "range": {"minimumValue": 1, "maximumValue": 100}, + } in position_state_mappings + call, _ = await assert_request_calls_service( "Alexa.RangeController", "SetRangeValue", @@ -2454,16 +2484,37 @@ async def test_cover_position_mode(hass): }, } in supported_modes - semantics = mode_capability["semantics"] - assert semantics is not None + # Assert for Position Semantics + position_semantics = mode_capability["semantics"] + assert position_semantics is not None - action_mappings = semantics["actionMappings"] - assert action_mappings is not None + position_action_mappings = position_semantics["actionMappings"] + assert position_action_mappings is not None + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Lower", "Alexa.Actions.Close"], + "directive": {"name": "SetMode", "payload": {"mode": "position.closed"}}, + } in position_action_mappings + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Raise", "Alexa.Actions.Open"], + "directive": {"name": "SetMode", "payload": {"mode": "position.open"}}, + } in position_action_mappings - state_mappings = semantics["stateMappings"] - assert state_mappings is not None + position_state_mappings = position_semantics["stateMappings"] + assert position_state_mappings is not None + assert { + "@type": "StatesToValue", + "states": ["Alexa.States.Closed"], + "value": "position.closed", + } in position_state_mappings + assert { + "@type": "StatesToValue", + "states": ["Alexa.States.Open"], + "value": "position.open", + } in position_state_mappings - call, msg = await assert_request_calls_service( + _, msg = await assert_request_calls_service( "Alexa.ModeController", "SetMode", "cover#test_mode", @@ -2477,7 +2528,7 @@ async def test_cover_position_mode(hass): assert properties["namespace"] == "Alexa.ModeController" assert properties["value"] == "position.closed" - call, msg = await assert_request_calls_service( + _, msg = await assert_request_calls_service( "Alexa.ModeController", "SetMode", "cover#test_mode", @@ -2491,7 +2542,7 @@ async def test_cover_position_mode(hass): assert properties["namespace"] == "Alexa.ModeController" assert properties["value"] == "position.open" - call, msg = await assert_request_calls_service( + _, msg = await assert_request_calls_service( "Alexa.ModeController", "SetMode", "cover#test_mode", @@ -2611,7 +2662,7 @@ async def test_cover_tilt_position_range(hass): range_capability = get_capability(capabilities, "Alexa.RangeController") assert range_capability is not None - assert range_capability["instance"] == "cover.tilt_position" + assert range_capability["instance"] == "cover.tilt" semantics = range_capability["semantics"] assert semantics is not None @@ -2629,7 +2680,7 @@ async def test_cover_tilt_position_range(hass): "cover.set_cover_tilt_position", hass, payload={"rangeValue": "50"}, - instance="cover.tilt_position", + instance="cover.tilt", ) assert call.data["position"] == 50 @@ -2640,7 +2691,7 @@ async def test_cover_tilt_position_range(hass): "cover.close_cover_tilt", hass, payload={"rangeValue": "0"}, - instance="cover.tilt_position", + instance="cover.tilt", ) properties = msg["context"]["properties"][0] assert properties["name"] == "rangeValue" @@ -2654,7 +2705,7 @@ async def test_cover_tilt_position_range(hass): "cover.open_cover_tilt", hass, payload={"rangeValue": "100"}, - instance="cover.tilt_position", + instance="cover.tilt", ) properties = msg["context"]["properties"][0] assert properties["name"] == "rangeValue" @@ -2670,12 +2721,12 @@ async def test_cover_tilt_position_range(hass): False, "cover.set_cover_tilt_position", "tilt_position", - instance="cover.tilt_position", + instance="cover.tilt", ) -async def test_cover_semantics(hass): - """Test cover discovery and semantics.""" +async def test_cover_semantics_position_and_tilt(hass): + """Test cover discovery and semantics with position and tilt support.""" device = ( "cover.test_semantics", "open", @@ -2697,50 +2748,57 @@ async def test_cover_semantics(hass): appliance, "Alexa.RangeController", "Alexa.EndpointHealth", "Alexa" ) - for range_instance in ("cover.position", "cover.tilt_position"): - range_capability = get_capability( - capabilities, "Alexa.RangeController", range_instance - ) - semantics = range_capability["semantics"] - assert semantics is not None + # Assert for Position Semantics + position_capability = get_capability( + capabilities, "Alexa.RangeController", "cover.position" + ) + position_semantics = position_capability["semantics"] + assert position_semantics is not None - action_mappings = semantics["actionMappings"] - assert action_mappings is not None - if range_instance == "cover.position": - assert { - "@type": "ActionsToDirective", - "actions": ["Alexa.Actions.Lower"], - "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 0}}, - } in action_mappings - assert { - "@type": "ActionsToDirective", - "actions": ["Alexa.Actions.Raise"], - "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 100}}, - } in action_mappings - elif range_instance == "cover.position": - assert { - "@type": "ActionsToDirective", - "actions": ["Alexa.Actions.Close"], - "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 0}}, - } in action_mappings - assert { - "@type": "ActionsToDirective", - "actions": ["Alexa.Actions.Open"], - "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 100}}, - } in action_mappings + position_action_mappings = position_semantics["actionMappings"] + assert position_action_mappings is not None + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Lower"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 0}}, + } in position_action_mappings + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Raise"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 100}}, + } in position_action_mappings - state_mappings = semantics["stateMappings"] - assert state_mappings is not None - assert { - "@type": "StatesToValue", - "states": ["Alexa.States.Closed"], - "value": 0, - } in state_mappings - assert { - "@type": "StatesToRange", - "states": ["Alexa.States.Open"], - "range": {"minimumValue": 1, "maximumValue": 100}, - } in state_mappings + # Assert for Tilt Semantics + tilt_capability = get_capability( + capabilities, "Alexa.RangeController", "cover.tilt" + ) + tilt_semantics = tilt_capability["semantics"] + assert tilt_semantics is not None + tilt_action_mappings = tilt_semantics["actionMappings"] + assert tilt_action_mappings is not None + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Close"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 0}}, + } in tilt_action_mappings + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Open"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 100}}, + } in tilt_action_mappings + + tilt_state_mappings = tilt_semantics["stateMappings"] + assert tilt_state_mappings is not None + assert { + "@type": "StatesToValue", + "states": ["Alexa.States.Closed"], + "value": 0, + } in tilt_state_mappings + assert { + "@type": "StatesToRange", + "states": ["Alexa.States.Open"], + "range": {"minimumValue": 1, "maximumValue": 100}, + } in tilt_state_mappings async def test_input_number(hass):