Fix Alexa semantics for covers with tilt support. (#30911)

* Fix Alexa semantics for covers with tilt support.

* Clarify wording.

* Korrect grammar.
This commit is contained in:
ochlocracy 2020-01-17 18:04:46 -05:00 committed by Paulus Schoutsen
parent 6ac33e5c7b
commit 6053d02e44
5 changed files with 207 additions and 90 deletions

View file

@ -1073,6 +1073,15 @@ class AlexaSecurityPanelController(AlexaCapability):
class AlexaModeController(AlexaCapability): class AlexaModeController(AlexaCapability):
"""Implements Alexa.ModeController. """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 https://developer.amazon.com/docs/device-apis/alexa-modecontroller.html
""" """
@ -1183,28 +1192,38 @@ class AlexaModeController(AlexaCapability):
def semantics(self): def semantics(self):
"""Build and return semantics object.""" """Build and return semantics object."""
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
# Cover Position # Cover Position
if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
lower_labels = [AlexaSemantics.ACTION_LOWER]
raise_labels = [AlexaSemantics.ACTION_RAISE]
self._semantics = AlexaSemantics() 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( self._semantics.add_action_to_directive(
[AlexaSemantics.ACTION_CLOSE, AlexaSemantics.ACTION_LOWER], lower_labels,
"SetMode", "SetMode",
{"mode": f"{cover.ATTR_POSITION}.{cover.STATE_CLOSED}"}, {"mode": f"{cover.ATTR_POSITION}.{cover.STATE_CLOSED}"},
) )
self._semantics.add_action_to_directive( self._semantics.add_action_to_directive(
[AlexaSemantics.ACTION_OPEN, AlexaSemantics.ACTION_RAISE], raise_labels,
"SetMode", "SetMode",
{"mode": f"{cover.ATTR_POSITION}.{cover.STATE_OPEN}"}, {"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 self._semantics.serialize_semantics()
return None return None
@ -1213,6 +1232,15 @@ class AlexaModeController(AlexaCapability):
class AlexaRangeController(AlexaCapability): class AlexaRangeController(AlexaCapability):
"""Implements Alexa.RangeController. """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 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}": if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
return self.entity.attributes.get(cover.ATTR_CURRENT_POSITION) return self.entity.attributes.get(cover.ATTR_CURRENT_POSITION)
# Cover Tilt Position # Cover Tilt
if self.instance == f"{cover.DOMAIN}.{cover.ATTR_TILT_POSITION}": if self.instance == f"{cover.DOMAIN}.tilt":
return self.entity.attributes.get(cover.ATTR_CURRENT_TILT_POSITION) return self.entity.attributes.get(cover.ATTR_CURRENT_TILT_POSITION)
# Input Number Value # Input Number Value
@ -1321,10 +1349,10 @@ class AlexaRangeController(AlexaCapability):
) )
return self._resource.serialize_capability_resources() return self._resource.serialize_capability_resources()
# Cover Tilt Position Resources # Cover Tilt Resources
if self.instance == f"{cover.DOMAIN}.{cover.ATTR_TILT_POSITION}": if self.instance == f"{cover.DOMAIN}.tilt":
self._resource = AlexaPresetResource( self._resource = AlexaPresetResource(
["Tilt Position", AlexaGlobalCatalog.SETTING_OPENING], ["Tilt", "Angle", AlexaGlobalCatalog.SETTING_DIRECTION],
min_value=0, min_value=0,
max_value=100, max_value=100,
precision=1, precision=1,
@ -1358,24 +1386,35 @@ class AlexaRangeController(AlexaCapability):
def semantics(self): def semantics(self):
"""Build and return semantics object.""" """Build and return semantics object."""
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
# Cover Position # Cover Position
if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
lower_labels = [AlexaSemantics.ACTION_LOWER]
raise_labels = [AlexaSemantics.ACTION_RAISE]
self._semantics = AlexaSemantics() 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( self._semantics.add_action_to_directive(
[AlexaSemantics.ACTION_LOWER], "SetRangeValue", {"rangeValue": 0} lower_labels, "SetRangeValue", {"rangeValue": 0}
) )
self._semantics.add_action_to_directive( self._semantics.add_action_to_directive(
[AlexaSemantics.ACTION_RAISE], "SetRangeValue", {"rangeValue": 100} raise_labels, "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
) )
return self._semantics.serialize_semantics() return self._semantics.serialize_semantics()
# Cover Tilt Position # Cover Tilt
if self.instance == f"{cover.DOMAIN}.{cover.ATTR_TILT_POSITION}": if self.instance == f"{cover.DOMAIN}.tilt":
self._semantics = AlexaSemantics() self._semantics = AlexaSemantics()
self._semantics.add_action_to_directive( self._semantics.add_action_to_directive(
[AlexaSemantics.ACTION_CLOSE], "SetRangeValue", {"rangeValue": 0} [AlexaSemantics.ACTION_CLOSE], "SetRangeValue", {"rangeValue": 0}
@ -1395,6 +1434,15 @@ class AlexaRangeController(AlexaCapability):
class AlexaToggleController(AlexaCapability): class AlexaToggleController(AlexaCapability):
"""Implements Alexa.ToggleController. """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 https://developer.amazon.com/docs/device-apis/alexa-togglecontroller.html
""" """

View file

@ -404,9 +404,7 @@ class CoverCapabilities(AlexaEntity):
self.entity, instance=f"{cover.DOMAIN}.{cover.ATTR_POSITION}" self.entity, instance=f"{cover.DOMAIN}.{cover.ATTR_POSITION}"
) )
if supported & cover.SUPPORT_SET_TILT_POSITION: if supported & cover.SUPPORT_SET_TILT_POSITION:
yield AlexaRangeController( yield AlexaRangeController(self.entity, instance=f"{cover.DOMAIN}.tilt")
self.entity, instance=f"{cover.DOMAIN}.{cover.ATTR_TILT_POSITION}"
)
yield AlexaEndpointHealth(self.hass, self.entity) yield AlexaEndpointHealth(self.hass, self.entity)
yield Alexa(self.hass) yield Alexa(self.hass)

View file

@ -1118,8 +1118,8 @@ async def async_api_set_range(hass, config, directive, context):
service = cover.SERVICE_SET_COVER_POSITION service = cover.SERVICE_SET_COVER_POSITION
data[cover.ATTR_POSITION] = range_value data[cover.ATTR_POSITION] = range_value
# Cover Tilt Position # Cover Tilt
elif instance == f"{cover.DOMAIN}.{cover.ATTR_TILT_POSITION}": elif instance == f"{cover.DOMAIN}.tilt":
range_value = int(range_value) range_value = int(range_value)
if range_value == 0: if range_value == 0:
service = cover.SERVICE_CLOSE_COVER_TILT 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) 100, max(0, range_delta + current)
) )
# Cover Tilt Position # Cover Tilt
elif instance == f"{cover.DOMAIN}.{cover.ATTR_TILT_POSITION}": elif instance == f"{cover.DOMAIN}.tilt":
range_delta = int(range_delta) range_delta = int(range_delta)
service = SERVICE_SET_COVER_TILT_POSITION service = SERVICE_SET_COVER_TILT_POSITION
current = entity.attributes.get(cover.ATTR_TILT_POSITION) current = entity.attributes.get(cover.ATTR_TILT_POSITION)

View file

@ -190,7 +190,12 @@ class AlexaGlobalCatalog:
class AlexaCapabilityResource: 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 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 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 https://developer.amazon.com/docs/device-apis/alexa-discovery.html#semantics-object
""" """

View file

@ -132,7 +132,7 @@ def get_capability(capabilities, capability_name, instance=None):
for capability in capabilities: for capability in capabilities:
if instance and capability["instance"] == instance: if instance and capability["instance"] == instance:
return capability return capability
elif capability["interface"] == capability_name: if not instance and capability["interface"] == capability_name:
return capability return capability
return None return None
@ -1427,6 +1427,36 @@ async def test_cover_position_range(hass):
assert supported_range["maximumValue"] == 100 assert supported_range["maximumValue"] == 100
assert supported_range["precision"] == 1 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( call, _ = await assert_request_calls_service(
"Alexa.RangeController", "Alexa.RangeController",
"SetRangeValue", "SetRangeValue",
@ -2454,16 +2484,37 @@ async def test_cover_position_mode(hass):
}, },
} in supported_modes } in supported_modes
semantics = mode_capability["semantics"] # Assert for Position Semantics
assert semantics is not None position_semantics = mode_capability["semantics"]
assert position_semantics is not None
action_mappings = semantics["actionMappings"] position_action_mappings = position_semantics["actionMappings"]
assert action_mappings is not None 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"] position_state_mappings = position_semantics["stateMappings"]
assert state_mappings is not None 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", "Alexa.ModeController",
"SetMode", "SetMode",
"cover#test_mode", "cover#test_mode",
@ -2477,7 +2528,7 @@ async def test_cover_position_mode(hass):
assert properties["namespace"] == "Alexa.ModeController" assert properties["namespace"] == "Alexa.ModeController"
assert properties["value"] == "position.closed" assert properties["value"] == "position.closed"
call, msg = await assert_request_calls_service( _, msg = await assert_request_calls_service(
"Alexa.ModeController", "Alexa.ModeController",
"SetMode", "SetMode",
"cover#test_mode", "cover#test_mode",
@ -2491,7 +2542,7 @@ async def test_cover_position_mode(hass):
assert properties["namespace"] == "Alexa.ModeController" assert properties["namespace"] == "Alexa.ModeController"
assert properties["value"] == "position.open" assert properties["value"] == "position.open"
call, msg = await assert_request_calls_service( _, msg = await assert_request_calls_service(
"Alexa.ModeController", "Alexa.ModeController",
"SetMode", "SetMode",
"cover#test_mode", "cover#test_mode",
@ -2611,7 +2662,7 @@ async def test_cover_tilt_position_range(hass):
range_capability = get_capability(capabilities, "Alexa.RangeController") range_capability = get_capability(capabilities, "Alexa.RangeController")
assert range_capability is not None assert range_capability is not None
assert range_capability["instance"] == "cover.tilt_position" assert range_capability["instance"] == "cover.tilt"
semantics = range_capability["semantics"] semantics = range_capability["semantics"]
assert semantics is not None assert semantics is not None
@ -2629,7 +2680,7 @@ async def test_cover_tilt_position_range(hass):
"cover.set_cover_tilt_position", "cover.set_cover_tilt_position",
hass, hass,
payload={"rangeValue": "50"}, payload={"rangeValue": "50"},
instance="cover.tilt_position", instance="cover.tilt",
) )
assert call.data["position"] == 50 assert call.data["position"] == 50
@ -2640,7 +2691,7 @@ async def test_cover_tilt_position_range(hass):
"cover.close_cover_tilt", "cover.close_cover_tilt",
hass, hass,
payload={"rangeValue": "0"}, payload={"rangeValue": "0"},
instance="cover.tilt_position", instance="cover.tilt",
) )
properties = msg["context"]["properties"][0] properties = msg["context"]["properties"][0]
assert properties["name"] == "rangeValue" assert properties["name"] == "rangeValue"
@ -2654,7 +2705,7 @@ async def test_cover_tilt_position_range(hass):
"cover.open_cover_tilt", "cover.open_cover_tilt",
hass, hass,
payload={"rangeValue": "100"}, payload={"rangeValue": "100"},
instance="cover.tilt_position", instance="cover.tilt",
) )
properties = msg["context"]["properties"][0] properties = msg["context"]["properties"][0]
assert properties["name"] == "rangeValue" assert properties["name"] == "rangeValue"
@ -2670,12 +2721,12 @@ async def test_cover_tilt_position_range(hass):
False, False,
"cover.set_cover_tilt_position", "cover.set_cover_tilt_position",
"tilt_position", "tilt_position",
instance="cover.tilt_position", instance="cover.tilt",
) )
async def test_cover_semantics(hass): async def test_cover_semantics_position_and_tilt(hass):
"""Test cover discovery and semantics.""" """Test cover discovery and semantics with position and tilt support."""
device = ( device = (
"cover.test_semantics", "cover.test_semantics",
"open", "open",
@ -2697,50 +2748,57 @@ async def test_cover_semantics(hass):
appliance, "Alexa.RangeController", "Alexa.EndpointHealth", "Alexa" appliance, "Alexa.RangeController", "Alexa.EndpointHealth", "Alexa"
) )
for range_instance in ("cover.position", "cover.tilt_position"): # Assert for Position Semantics
range_capability = get_capability( position_capability = get_capability(
capabilities, "Alexa.RangeController", range_instance capabilities, "Alexa.RangeController", "cover.position"
) )
semantics = range_capability["semantics"] position_semantics = position_capability["semantics"]
assert semantics is not None assert position_semantics is not None
action_mappings = semantics["actionMappings"] position_action_mappings = position_semantics["actionMappings"]
assert action_mappings is not None assert position_action_mappings is not None
if range_instance == "cover.position": assert {
assert { "@type": "ActionsToDirective",
"@type": "ActionsToDirective", "actions": ["Alexa.Actions.Lower"],
"actions": ["Alexa.Actions.Lower"], "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 0}},
"directive": {"name": "SetRangeValue", "payload": {"rangeValue": 0}}, } in position_action_mappings
} in action_mappings assert {
assert { "@type": "ActionsToDirective",
"@type": "ActionsToDirective", "actions": ["Alexa.Actions.Raise"],
"actions": ["Alexa.Actions.Raise"], "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 100}},
"directive": {"name": "SetRangeValue", "payload": {"rangeValue": 100}}, } in position_action_mappings
} 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
state_mappings = semantics["stateMappings"] # Assert for Tilt Semantics
assert state_mappings is not None tilt_capability = get_capability(
assert { capabilities, "Alexa.RangeController", "cover.tilt"
"@type": "StatesToValue", )
"states": ["Alexa.States.Closed"], tilt_semantics = tilt_capability["semantics"]
"value": 0, assert tilt_semantics is not None
} in state_mappings tilt_action_mappings = tilt_semantics["actionMappings"]
assert { assert tilt_action_mappings is not None
"@type": "StatesToRange", assert {
"states": ["Alexa.States.Open"], "@type": "ActionsToDirective",
"range": {"minimumValue": 1, "maximumValue": 100}, "actions": ["Alexa.Actions.Close"],
} in state_mappings "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): async def test_input_number(hass):