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

* Fix Alexa semantics for covers with tilt support.

* Clarify wording.

* Korrect grammar.
pull/30924/head
ochlocracy 2020-01-17 18:04:46 -05:00 committed by Paulus Schoutsen
parent e33698b17d
commit 1d41cf96ca
5 changed files with 207 additions and 90 deletions

View File

@ -1091,6 +1091,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
"""
@ -1201,28 +1210,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
@ -1231,6 +1250,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
"""
@ -1290,8 +1318,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
@ -1350,10 +1378,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,
@ -1407,24 +1435,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}
@ -1444,6 +1483,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
"""

View File

@ -410,9 +410,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)

View File

@ -1120,8 +1120,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
@ -1215,8 +1215,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)

View File

@ -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
"""

View File

@ -133,7 +133,7 @@ def get_capability(capabilities, capability_name, instance=None):
for capability in capabilities:
if instance and capability["instance"] == instance:
return capability
if capability["interface"] == capability_name:
if not instance and capability["interface"] == capability_name:
return capability
return None
@ -1484,6 +1484,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",
@ -2511,16 +2541,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",
@ -2534,7 +2585,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",
@ -2548,7 +2599,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",
@ -2668,7 +2719,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
@ -2686,7 +2737,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
@ -2697,7 +2748,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"
@ -2711,7 +2762,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"
@ -2727,12 +2778,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",
@ -2754,50 +2805,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):