Refactor Alexa capabilityResources object into class, Implement Alexa semantics object (#29917)

* Refactor capabilityResources object into class.
Implement semantics object to support open, close, raise, lower utterences.
Replace covers PercentageController with RangeController.
Add semantics for covers.
Remove PowerController for covers.
Add new display categories.
Add new items to Alexa Global Catalog.
Implement garage door voice PIN code support though Alexa app.
Fixed bug with getting property for ModeController.
Fixed bug were PercentageController AdjustPercentage would exceed 100.

* Comment fixes in Tests.

* Reorder imports.

* Added additional tests for more code coverage.

* Added and additional test for more code coverage.

* Explicitly return None for configuration() if not instance of AlexaCapabilityResource.
This commit is contained in:
ochlocracy 2019-12-19 06:44:17 -05:00 committed by Paulus Schoutsen
parent 9804fbb527
commit 5baaa852dd
7 changed files with 1123 additions and 487 deletions

View file

@ -13,11 +13,9 @@ 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,
@ -34,10 +32,16 @@ from .const import (
DATE_FORMAT,
PERCENTAGE_FAN_MAP,
RANGE_FAN_MAP,
Catalog,
Inputs,
)
from .errors import UnsupportedProperty
from .resources import (
AlexaCapabilityResource,
AlexaGlobalCatalog,
AlexaModeResource,
AlexaPresetResource,
AlexaSemantics,
)
_LOGGER = logging.getLogger(__name__)
@ -108,12 +112,15 @@ class AlexaCapability:
@staticmethod
def capability_resources():
"""Applicable to ToggleController, RangeController, and ModeController interfaces."""
"""Return the capability object.
Applicable to ToggleController, RangeController, and ModeController interfaces.
"""
return []
@staticmethod
def configuration():
"""Return the Configuration object."""
"""Return the configuration object."""
return []
@staticmethod
@ -121,6 +128,14 @@ class AlexaCapability:
"""Applicable only to media players."""
return []
@staticmethod
def semantics():
"""Return the semantics object.
Applicable to ToggleController, RangeController, and ModeController interfaces.
"""
return []
@staticmethod
def supported_operations():
"""Return the supportedOperations object."""
@ -130,6 +145,10 @@ class AlexaCapability:
"""Serialize according to the Discovery API."""
result = {"type": "AlexaInterface", "interface": self.name(), "version": "3"}
instance = self.instance
if instance is not None:
result["instance"] = instance
properties_supported = self.properties_supported()
if properties_supported:
result["properties"] = {
@ -138,22 +157,19 @@ class AlexaCapability:
"retrievable": self.properties_retrievable(),
}
# pylint: disable=assignment-from-none
proactively_reported = self.capability_proactively_reported()
if proactively_reported is not None:
result["proactivelyReported"] = proactively_reported
# pylint: disable=assignment-from-none
non_controllable = self.properties_non_controllable()
if non_controllable is not None:
result["properties"]["nonControllable"] = non_controllable
# pylint: disable=assignment-from-none
supports_deactivation = self.supports_deactivation()
if supports_deactivation is not None:
result["supportsDeactivation"] = supports_deactivation
capability_resources = self.serialize_capability_resources()
capability_resources = self.capability_resources()
if capability_resources:
result["capabilityResources"] = capability_resources
@ -161,10 +177,9 @@ class AlexaCapability:
if configuration:
result["configuration"] = configuration
# pylint: disable=assignment-from-none
instance = self.instance
if instance is not None:
result["instance"] = instance
semantics = self.semantics()
if semantics:
result["semantics"] = semantics
supported_operations = self.supported_operations()
if supported_operations:
@ -196,36 +211,6 @@ class AlexaCapability:
yield result
def serialize_capability_resources(self):
"""Return capabilityResources friendlyNames serialized for an API response."""
resources = self.capability_resources()
if resources:
return {"friendlyNames": self.serialize_friendly_names(resources)}
return None
@staticmethod
def serialize_friendly_names(resources):
"""Return capabilityResources, ModeResources, or presetResources friendlyNames serialized for an API response."""
friendly_names = []
for resource in resources:
if resource["type"] == Catalog.LABEL_ASSET:
friendly_names.append(
{
"@type": Catalog.LABEL_ASSET,
"value": {"assetId": resource["value"]},
}
)
else:
friendly_names.append(
{
"@type": Catalog.LABEL_TEXT,
"value": {"text": resource["value"], "locale": "en-US"},
}
)
return friendly_names
class Alexa(AlexaCapability):
"""Implements Alexa Interface.
@ -906,6 +891,8 @@ class AlexaModeController(AlexaCapability):
def __init__(self, entity, instance, non_controllable=False):
"""Initialize the entity."""
super().__init__(entity, instance)
self._resource = None
self._semantics = None
self.properties_non_controllable = lambda: non_controllable
def name(self):
@ -922,108 +909,102 @@ class AlexaModeController(AlexaCapability):
def properties_retrievable(self):
"""Return True if properties can be retrieved."""
return True
def get_property(self, name):
"""Read and return a property."""
if name != "mode":
raise UnsupportedProperty(name)
# Fan Direction
if self.instance == f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}":
return self.entity.attributes.get(fan.ATTR_DIRECTION)
mode = self.entity.attributes.get(fan.ATTR_DIRECTION, None)
if mode in (fan.DIRECTION_FORWARD, fan.DIRECTION_REVERSE, STATE_UNKNOWN):
return f"{fan.ATTR_DIRECTION}.{mode}"
# Cover Position
if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
return self.entity.attributes.get(cover.ATTR_POSITION)
# Return state instead of position when using ModeController.
mode = self.entity.state
if mode in (
cover.STATE_OPEN,
cover.STATE_OPENING,
cover.STATE_CLOSED,
cover.STATE_CLOSING,
STATE_UNKNOWN,
):
return f"{cover.ATTR_POSITION}.{mode}"
return None
def configuration(self):
"""Return configuration with modeResources."""
return self.serialize_mode_resources()
if isinstance(self._resource, AlexaCapabilityResource):
return self._resource.serialize_configuration()
return None
def capability_resources(self):
"""Return capabilityResources object."""
capability_resources = []
# Fan Direction Resource
if self.instance == f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}":
capability_resources = [
{"type": Catalog.LABEL_ASSET, "value": Catalog.SETTING_DIRECTION}
]
self._resource = AlexaModeResource(
[AlexaGlobalCatalog.SETTING_DIRECTION], False
)
self._resource.add_mode(
f"{fan.ATTR_DIRECTION}.{fan.DIRECTION_FORWARD}", [fan.DIRECTION_FORWARD]
)
self._resource.add_mode(
f"{fan.ATTR_DIRECTION}.{fan.DIRECTION_REVERSE}", [fan.DIRECTION_REVERSE]
)
return self._resource.serialize_capability_resources()
# Cover Position Resources
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},
]
self._resource = AlexaModeResource(
["Position", AlexaGlobalCatalog.SETTING_OPENING], False
)
self._resource.add_mode(
f"{cover.ATTR_POSITION}.{cover.STATE_OPEN}",
[AlexaGlobalCatalog.VALUE_OPEN],
)
self._resource.add_mode(
f"{cover.ATTR_POSITION}.{cover.STATE_CLOSED}",
[AlexaGlobalCatalog.VALUE_CLOSE],
)
self._resource.add_mode(f"{cover.ATTR_POSITION}.custom", ["Custom"])
return self._resource.serialize_capability_resources()
return capability_resources
return None
def mode_resources(self):
"""Return modeResources object."""
mode_resources = None
if self.instance == f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}":
mode_resources = {
"ordered": False,
"resources": [
{
"value": f"{fan.ATTR_DIRECTION}.{fan.DIRECTION_FORWARD}",
"friendly_names": [
{"type": Catalog.LABEL_TEXT, "value": fan.DIRECTION_FORWARD}
],
},
{
"value": f"{fan.ATTR_DIRECTION}.{fan.DIRECTION_REVERSE}",
"friendly_names": [
{"type": Catalog.LABEL_TEXT, "value": fan.DIRECTION_REVERSE}
],
},
],
}
def semantics(self):
"""Build and return semantics object."""
# Cover Position
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"},
],
},
],
}
self._semantics = AlexaSemantics()
self._semantics.add_action_to_directive(
[AlexaSemantics.ACTION_CLOSE, AlexaSemantics.ACTION_LOWER],
"SetMode",
{"mode": f"{cover.ATTR_POSITION}.{cover.STATE_CLOSED}"},
)
self._semantics.add_action_to_directive(
[AlexaSemantics.ACTION_OPEN, AlexaSemantics.ACTION_RAISE],
"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 mode_resources
def serialize_mode_resources(self):
"""Return ModeResources, friendlyNames serialized for an API response."""
mode_resources = []
resources = self.mode_resources()
ordered = resources["ordered"]
for resource in resources["resources"]:
mode_value = resource["value"]
friendly_names = resource["friendly_names"]
result = {
"value": mode_value,
"modeResources": {
"friendlyNames": self.serialize_friendly_names(friendly_names)
},
}
mode_resources.append(result)
return {"ordered": ordered, "supportedModes": mode_resources}
return None
class AlexaRangeController(AlexaCapability):
@ -1035,6 +1016,8 @@ class AlexaRangeController(AlexaCapability):
def __init__(self, entity, instance, non_controllable=False):
"""Initialize the entity."""
super().__init__(entity, instance)
self._resource = None
self._semantics = None
self.properties_non_controllable = lambda: non_controllable
def name(self):
@ -1058,88 +1041,111 @@ class AlexaRangeController(AlexaCapability):
if name != "rangeValue":
raise UnsupportedProperty(name)
# Fan Speed
if self.instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}":
speed = self.entity.attributes.get(fan.ATTR_SPEED)
return RANGE_FAN_MAP.get(speed, 0)
# Cover Position
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}":
return self.entity.attributes.get(cover.ATTR_CURRENT_TILT_POSITION)
return None
def configuration(self):
"""Return configuration with presetResources."""
return self.serialize_preset_resources()
if isinstance(self._resource, AlexaCapabilityResource):
return self._resource.serialize_configuration()
return None
def capability_resources(self):
"""Return capabilityResources object."""
capability_resources = []
# Fan Speed Resources
if self.instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}":
return [{"type": Catalog.LABEL_ASSET, "value": Catalog.SETTING_FANSPEED}]
return capability_resources
def preset_resources(self):
"""Return presetResources object."""
preset_resources = []
if self.instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}":
preset_resources = {
"minimumValue": 1,
"maximumValue": 3,
"precision": 1,
"presets": [
{
"rangeValue": 1,
"names": [
{
"type": Catalog.LABEL_ASSET,
"value": Catalog.VALUE_MINIMUM,
},
{"type": Catalog.LABEL_ASSET, "value": Catalog.VALUE_LOW},
],
},
{
"rangeValue": 2,
"names": [
{"type": Catalog.LABEL_ASSET, "value": Catalog.VALUE_MEDIUM}
],
},
{
"rangeValue": 3,
"names": [
{
"type": Catalog.LABEL_ASSET,
"value": Catalog.VALUE_MAXIMUM,
},
{"type": Catalog.LABEL_ASSET, "value": Catalog.VALUE_HIGH},
],
},
],
}
return preset_resources
def serialize_preset_resources(self):
"""Return PresetResources, friendlyNames serialized for an API response."""
preset_resources = []
resources = self.preset_resources()
for preset in resources["presets"]:
preset_resources.append(
{
"rangeValue": preset["rangeValue"],
"presetResources": {
"friendlyNames": self.serialize_friendly_names(preset["names"])
},
}
self._resource = AlexaPresetResource(
labels=[AlexaGlobalCatalog.SETTING_FAN_SPEED],
min_value=1,
max_value=3,
precision=1,
)
self._resource.add_preset(
value=1,
labels=[AlexaGlobalCatalog.VALUE_LOW, AlexaGlobalCatalog.VALUE_MINIMUM],
)
self._resource.add_preset(value=2, labels=[AlexaGlobalCatalog.VALUE_MEDIUM])
self._resource.add_preset(
value=3,
labels=[
AlexaGlobalCatalog.VALUE_HIGH,
AlexaGlobalCatalog.VALUE_MAXIMUM,
],
)
return self._resource.serialize_capability_resources()
return {
"supportedRange": {
"minimumValue": resources["minimumValue"],
"maximumValue": resources["maximumValue"],
"precision": resources["precision"],
},
"presets": preset_resources,
}
# Cover Position Resources
if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
self._resource = AlexaPresetResource(
["Position", AlexaGlobalCatalog.SETTING_OPENING],
min_value=0,
max_value=100,
precision=1,
unit=AlexaGlobalCatalog.UNIT_PERCENT,
)
return self._resource.serialize_capability_resources()
# Cover Tilt Position Resources
if self.instance == f"{cover.DOMAIN}.{cover.ATTR_TILT_POSITION}":
self._resource = AlexaPresetResource(
["Tilt Position", AlexaGlobalCatalog.SETTING_OPENING],
min_value=0,
max_value=100,
precision=1,
unit=AlexaGlobalCatalog.UNIT_PERCENT,
)
return self._resource.serialize_capability_resources()
return None
def semantics(self):
"""Build and return semantics object."""
# Cover Position
if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
self._semantics = AlexaSemantics()
self._semantics.add_action_to_directive(
[AlexaSemantics.ACTION_LOWER], "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
)
return self._semantics.serialize_semantics()
# Cover Tilt Position
if self.instance == f"{cover.DOMAIN}.{cover.ATTR_TILT_POSITION}":
self._semantics = AlexaSemantics()
self._semantics.add_action_to_directive(
[AlexaSemantics.ACTION_CLOSE], "SetRangeValue", {"rangeValue": 0}
)
self._semantics.add_action_to_directive(
[AlexaSemantics.ACTION_OPEN], "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 None
class AlexaToggleController(AlexaCapability):
@ -1151,6 +1157,8 @@ class AlexaToggleController(AlexaCapability):
def __init__(self, entity, instance, non_controllable=False):
"""Initialize the entity."""
super().__init__(entity, instance)
self._resource = None
self._semantics = None
self.properties_non_controllable = lambda: non_controllable
def name(self):
@ -1174,6 +1182,7 @@ class AlexaToggleController(AlexaCapability):
if name != "toggleState":
raise UnsupportedProperty(name)
# Fan Oscillating
if self.instance == f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}":
is_on = bool(self.entity.attributes.get(fan.ATTR_OSCILLATING))
return "ON" if is_on else "OFF"
@ -1182,16 +1191,15 @@ class AlexaToggleController(AlexaCapability):
def capability_resources(self):
"""Return capabilityResources object."""
capability_resources = []
# Fan Oscillating Resource
if self.instance == f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}":
capability_resources = [
{"type": Catalog.LABEL_ASSET, "value": Catalog.SETTING_OSCILLATE},
{"type": Catalog.LABEL_TEXT, "value": "Rotate"},
{"type": Catalog.LABEL_TEXT, "value": "Rotation"},
]
self._resource = AlexaCapabilityResource(
[AlexaGlobalCatalog.SETTING_OSCILLATE, "Rotate", "Rotation"]
)
return self._resource.serialize_capability_resources()
return capability_resources
return None
class AlexaChannelController(AlexaCapability):

View file

@ -117,163 +117,6 @@ class Cause:
VOICE_INTERACTION = "VOICE_INTERACTION"
class Catalog:
"""The Global Alexa catalog.
https://developer.amazon.com/docs/device-apis/resources-and-assets.html#global-alexa-catalog
You can use the global Alexa catalog for pre-defined names of devices, settings, values, and units.
This catalog is localized into all the languages that Alexa supports.
You can reference the following catalog of pre-defined friendly names.
Each item in the following list is an asset identifier followed by its supported friendly names.
The first friendly name for each identifier is the one displayed in the Alexa mobile app.
"""
LABEL_ASSET = "asset"
LABEL_TEXT = "text"
# Shower
DEVICENAME_SHOWER = "Alexa.DeviceName.Shower"
# Washer, Washing Machine
DEVICENAME_WASHER = "Alexa.DeviceName.Washer"
# Router, Internet Router, Network Router, Wifi Router, Net Router
DEVICENAME_ROUTER = "Alexa.DeviceName.Router"
# Fan, Blower
DEVICENAME_FAN = "Alexa.DeviceName.Fan"
# Air Purifier, Air Cleaner,Clean Air Machine
DEVICENAME_AIRPURIFIER = "Alexa.DeviceName.AirPurifier"
# Space Heater, Portable Heater
DEVICENAME_SPACEHEATER = "Alexa.DeviceName.SpaceHeater"
# Rain Head, Overhead shower, Rain Shower, Rain Spout, Rain Faucet
SHOWER_RAINHEAD = "Alexa.Shower.RainHead"
# Handheld Shower, Shower Wand, Hand Shower
SHOWER_HANDHELD = "Alexa.Shower.HandHeld"
# Water Temperature, Water Temp, Water Heat
SETTING_WATERTEMPERATURE = "Alexa.Setting.WaterTemperature"
# Temperature, Temp
SETTING_TEMPERATURE = "Alexa.Setting.Temperature"
# Wash Cycle, Wash Preset, Wash setting
SETTING_WASHCYCLE = "Alexa.Setting.WashCycle"
# 2.4G Guest Wi-Fi, 2.4G Guest Network, Guest Network 2.4G, 2G Guest Wifi
SETTING_2GGUESTWIFI = "Alexa.Setting.2GGuestWiFi"
# 5G Guest Wi-Fi, 5G Guest Network, Guest Network 5G, 5G Guest Wifi
SETTING_5GGUESTWIFI = "Alexa.Setting.5GGuestWiFi"
# Guest Wi-fi, Guest Network, Guest Net
SETTING_GUESTWIFI = "Alexa.Setting.GuestWiFi"
# Auto, Automatic, Automatic Mode, Auto Mode
SETTING_AUTO = "Alexa.Setting.Auto"
# #Night, Night Mode
SETTING_NIGHT = "Alexa.Setting.Night"
# Quiet, Quiet Mode, Noiseless, Silent
SETTING_QUIET = "Alexa.Setting.Quiet"
# Oscillate, Swivel, Oscillation, Spin, Back and forth
SETTING_OSCILLATE = "Alexa.Setting.Oscillate"
# Fan Speed, Airflow speed, Wind Speed, Air speed, Air velocity
SETTING_FANSPEED = "Alexa.Setting.FanSpeed"
# Preset, Setting
SETTING_PRESET = "Alexa.Setting.Preset"
# Mode
SETTING_MODE = "Alexa.Setting.Mode"
# Direction
SETTING_DIRECTION = "Alexa.Setting.Direction"
# Delicates, Delicate
VALUE_DELICATE = "Alexa.Value.Delicate"
# Quick Wash, Fast Wash, Wash Quickly, Speed Wash
VALUE_QUICKWASH = "Alexa.Value.QuickWash"
# Maximum, Max
VALUE_MAXIMUM = "Alexa.Value.Maximum"
# Minimum, Min
VALUE_MINIMUM = "Alexa.Value.Minimum"
# High
VALUE_HIGH = "Alexa.Value.High"
# Low
VALUE_LOW = "Alexa.Value.Low"
# Medium, Mid
VALUE_MEDIUM = "Alexa.Value.Medium"
class Unit:
"""Alexa Units of Measure.
https://developer.amazon.com/docs/device-apis/alexa-property-schemas.html#units-of-measure
"""
ANGLE_DEGREES = "Alexa.Unit.Angle.Degrees"
ANGLE_RADIANS = "Alexa.Unit.Angle.Radians"
DISTANCE_FEET = "Alexa.Unit.Distance.Feet"
DISTANCE_INCHES = "Alexa.Unit.Distance.Inches"
DISTANCE_KILOMETERS = "Alexa.Unit.Distance.Kilometers"
DISTANCE_METERS = "Alexa.Unit.Distance.Meters"
DISTANCE_MILES = "Alexa.Unit.Distance.Miles"
DISTANCE_YARDS = "Alexa.Unit.Distance.Yards"
MASS_GRAMS = "Alexa.Unit.Mass.Grams"
MASS_KILOGRAMS = "Alexa.Unit.Mass.Kilograms"
PERCENT = "Alexa.Unit.Percent"
TEMPERATURE_CELSIUS = "Alexa.Unit.Temperature.Celsius"
TEMPERATURE_DEGREES = "Alexa.Unit.Temperature.Degrees"
TEMPERATURE_FAHRENHEIT = "Alexa.Unit.Temperature.Fahrenheit"
TEMPERATURE_KELVIN = "Alexa.Unit.Temperature.Kelvin"
VOLUME_CUBICFEET = "Alexa.Unit.Volume.CubicFeet"
VOLUME_CUBICMETERS = "Alexa.Unit.Volume.CubicMeters"
VOLUME_GALLONS = "Alexa.Unit.Volume.Gallons"
VOLUME_LITERS = "Alexa.Unit.Volume.Liters"
VOLUME_PINTS = "Alexa.Unit.Volume.Pints"
VOLUME_QUARTS = "Alexa.Unit.Volume.Quarts"
WEIGHT_OUNCES = "Alexa.Unit.Weight.Ounces"
WEIGHT_POUNDS = "Alexa.Unit.Weight.Pounds"
class Inputs:
"""Valid names for the InputController.

View file

@ -83,6 +83,9 @@ class DisplayCategory:
# Indicates media devices with video or photo capabilities.
CAMERA = "CAMERA"
# Indicates a non-mobile computer, such as a desktop computer.
COMPUTER = "COMPUTER"
# Indicates an endpoint that detects and reports contact.
CONTACT_SENSOR = "CONTACT_SENSOR"
@ -92,27 +95,60 @@ class DisplayCategory:
# Indicates a doorbell.
DOORBELL = "DOORBELL"
# Indicates a window covering on the outside of a structure.
EXTERIOR_BLIND = "EXTERIOR_BLIND"
# Indicates a fan.
FAN = "FAN"
# Indicates a game console, such as Microsoft Xbox or Nintendo Switch
GAME_CONSOLE = "GAME_CONSOLE"
# Indicates a garage door. Garage doors must implement the ModeController interface to open and close the door.
GARAGE_DOOR = "GARAGE_DOOR"
# Indicates a window covering on the inside of a structure.
INTERIOR_BLIND = "INTERIOR_BLIND"
# Indicates a laptop or other mobile computer.
LAPTOP = "LAPTOP"
# Indicates light sources or fixtures.
LIGHT = "LIGHT"
# Indicates a microwave oven.
MICROWAVE = "MICROWAVE"
# Indicates a mobile phone.
MOBILE_PHONE = "MOBILE_PHONE"
# Indicates an endpoint that detects and reports motion.
MOTION_SENSOR = "MOTION_SENSOR"
# Indicates a network-connected music system.
MUSIC_SYSTEM = "MUSIC_SYSTEM"
# An endpoint that cannot be described in on of the other categories.
OTHER = "OTHER"
# Indicates a network router.
NETWORK_HARDWARE = "NETWORK_HARDWARE"
# Indicates an oven cooking appliance.
OVEN = "OVEN"
# Indicates a non-mobile phone, such as landline or an IP phone.
PHONE = "PHONE"
# Describes a combination of devices set to a specific state, when the
# order of the state change is not important. For example a bedtime scene
# might include turning off lights and lowering the thermostat, but the
# order is unimportant. Applies to Scenes
SCENE_TRIGGER = "SCENE_TRIGGER"
# Indicates a projector screen.
SCREEN = "SCREEN"
# Indicates a security panel.
SECURITY_PANEL = "SECURITY_PANEL"
@ -126,10 +162,16 @@ class DisplayCategory:
# Indicates the endpoint is a speaker or speaker system.
SPEAKER = "SPEAKER"
# Indicates a streaming device such as Apple TV, Chromecast, or Roku.
STREAMING_DEVICE = "STREAMING_DEVICE"
# Indicates in-wall switches wired to the electrical system. Can control a
# variety of devices.
SWITCH = "SWITCH"
# Indicates a tablet computer.
TABLET = "TABLET"
# Indicates endpoints that report the temperature only.
TEMPERATURE_SENSOR = "TEMPERATURE_SENSOR"
@ -140,6 +182,9 @@ class DisplayCategory:
# Indicates the endpoint is a television.
TV = "TV"
# Indicates a network-connected wearable device, such as an Apple Watch, Fitbit, or Samsung Gear.
WEARABLE = "WEARABLE"
class AlexaEntity:
"""An adaptation of an entity, expressed in Alexa's terms.
@ -318,20 +363,40 @@ class CoverCapabilities(AlexaEntity):
def default_display_categories(self):
"""Return the display categories for this entity."""
device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS)
if device_class in (cover.DEVICE_CLASS_GARAGE, cover.DEVICE_CLASS_DOOR):
if device_class == cover.DEVICE_CLASS_GARAGE:
return [DisplayCategory.GARAGE_DOOR]
if device_class == cover.DEVICE_CLASS_DOOR:
return [DisplayCategory.DOOR]
if device_class in (
cover.DEVICE_CLASS_BLIND,
cover.DEVICE_CLASS_SHADE,
cover.DEVICE_CLASS_CURTAIN,
):
return [DisplayCategory.INTERIOR_BLIND]
if device_class in (
cover.DEVICE_CLASS_WINDOW,
cover.DEVICE_CLASS_AWNING,
cover.DEVICE_CLASS_SHUTTER,
):
return [DisplayCategory.EXTERIOR_BLIND]
return [DisplayCategory.OTHER]
def interfaces(self):
"""Yield the supported interfaces."""
yield AlexaPowerController(self.entity)
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 AlexaRangeController(
self.entity, instance=f"{cover.DOMAIN}.{cover.ATTR_POSITION}"
)
elif supported & (cover.SUPPORT_CLOSE | cover.SUPPORT_OPEN):
yield AlexaModeController(
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 AlexaEndpointHealth(self.hass, self.entity)
yield Alexa(self.hass)
@ -355,6 +420,7 @@ class LightCapabilities(AlexaEntity):
yield AlexaColorController(self.entity)
if supported & light.SUPPORT_COLOR_TEMP:
yield AlexaColorTemperatureController(self.entity)
yield AlexaEndpointHealth(self.hass, self.entity)
yield Alexa(self.hass)
@ -370,6 +436,7 @@ class FanCapabilities(AlexaEntity):
def interfaces(self):
"""Yield the supported interfaces."""
yield AlexaPowerController(self.entity)
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if supported & fan.SUPPORT_SET_SPEED:
yield AlexaPercentageController(self.entity)
@ -377,7 +444,6 @@ class FanCapabilities(AlexaEntity):
yield AlexaRangeController(
self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_SPEED}"
)
if supported & fan.SUPPORT_OSCILLATE:
yield AlexaToggleController(
self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}"

View file

@ -20,6 +20,7 @@ from homeassistant.const import (
SERVICE_MEDIA_PREVIOUS_TRACK,
SERVICE_MEDIA_STOP,
SERVICE_SET_COVER_POSITION,
SERVICE_SET_COVER_TILT_POSITION,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
SERVICE_UNLOCK,
@ -28,8 +29,6 @@ from homeassistant.const import (
SERVICE_VOLUME_SET,
SERVICE_VOLUME_UP,
STATE_ALARM_DISARMED,
STATE_CLOSED,
STATE_OPEN,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
)
@ -113,9 +112,7 @@ async def async_api_turn_on(hass, config, directive, context):
domain = ha.DOMAIN
service = SERVICE_TURN_ON
if domain == cover.DOMAIN:
service = cover.SERVICE_OPEN_COVER
elif domain == media_player.DOMAIN:
if domain == media_player.DOMAIN:
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
power_features = media_player.SUPPORT_TURN_ON | media_player.SUPPORT_TURN_OFF
if not supported & power_features:
@ -141,9 +138,7 @@ async def async_api_turn_off(hass, config, directive, context):
domain = ha.DOMAIN
service = SERVICE_TURN_OFF
if entity.domain == cover.DOMAIN:
service = cover.SERVICE_CLOSE_COVER
elif domain == media_player.DOMAIN:
if domain == media_player.DOMAIN:
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
power_features = media_player.SUPPORT_TURN_ON | media_player.SUPPORT_TURN_OFF
if not supported & power_features:
@ -348,10 +343,6 @@ async def async_api_set_percentage(hass, config, directive, context):
speed = "high"
data[fan.ATTR_SPEED] = speed
elif entity.domain == cover.DOMAIN:
service = SERVICE_SET_COVER_POSITION
data[cover.ATTR_POSITION] = percentage
await hass.services.async_call(
entity.domain, service, data, blocking=False, context=context
)
@ -385,13 +376,6 @@ async def async_api_adjust_percentage(hass, config, directive, context):
data[fan.ATTR_SPEED] = speed
elif entity.domain == cover.DOMAIN:
service = SERVICE_SET_COVER_POSITION
current = entity.attributes.get(cover.ATTR_POSITION)
data[cover.ATTR_POSITION] = max(0, percentage_delta + current)
await hass.services.async_call(
entity.domain, service, data, blocking=False, context=context
)
@ -960,32 +944,35 @@ async def async_api_disarm(hass, config, directive, context):
@HANDLERS.register(("Alexa.ModeController", "SetMode"))
async def async_api_set_mode(hass, config, directive, context):
"""Process a next request."""
"""Process a SetMode directive."""
entity = directive.entity
instance = directive.instance
domain = entity.domain
service = None
data = {ATTR_ENTITY_ID: entity.entity_id}
capability_mode = directive.payload["mode"]
if domain not in (fan.DOMAIN, cover.DOMAIN):
msg = "Entity does not support directive"
raise AlexaInvalidDirectiveError(msg)
mode = directive.payload["mode"]
# Fan Direction
if instance == f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}":
_, direction = capability_mode.split(".")
_, direction = 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(".")
# Cover Position
elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
_, position = mode.split(".")
if position == STATE_CLOSED:
if position == cover.STATE_CLOSED:
service = cover.SERVICE_CLOSE_COVER
if position == STATE_OPEN:
elif position == cover.STATE_OPEN:
service = cover.SERVICE_OPEN_COVER
elif position == "custom":
service = cover.SERVICE_STOP_COVER
else:
msg = "Entity does not support directive"
raise AlexaInvalidDirectiveError(msg)
await hass.services.async_call(
domain, service, data, blocking=False, context=context
@ -997,7 +984,7 @@ async def async_api_set_mode(hass, config, directive, context):
"namespace": "Alexa.ModeController",
"instance": instance,
"name": "mode",
"value": capability_mode,
"value": mode,
}
)
@ -1008,24 +995,13 @@ async def async_api_set_mode(hass, config, directive, context):
async def async_api_adjust_mode(hass, config, directive, context):
"""Process a AdjustMode request.
Requires modeResources to be ordered.
Only modes that are ordered support the adjustMode directive.
Requires capabilityResources supportedModes to be ordered.
Only supportedModes with ordered=True support the adjustMode directive.
"""
entity = directive.entity
instance = directive.instance
domain = entity.domain
if domain != fan.DOMAIN:
msg = "Entity does not support directive"
raise AlexaInvalidDirectiveError(msg)
if instance is None:
msg = "Entity does not support directive"
raise AlexaInvalidDirectiveError(msg)
# No modeResources are currently ordered to support this request.
return directive.response()
# Currently no supportedModes are configured with ordered=True to support this request.
msg = "Entity does not support directive"
raise AlexaInvalidDirectiveError(msg)
@HANDLERS.register(("Alexa.ToggleController", "TurnOn"))
@ -1037,19 +1013,29 @@ async def async_api_toggle_on(hass, config, directive, context):
service = None
data = {ATTR_ENTITY_ID: entity.entity_id}
if domain != fan.DOMAIN:
msg = "Entity does not support directive"
raise AlexaInvalidDirectiveError(msg)
# Fan Oscillating
if instance == f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}":
service = fan.SERVICE_OSCILLATE
data[fan.ATTR_OSCILLATING] = True
else:
msg = "Entity does not support directive"
raise AlexaInvalidDirectiveError(msg)
await hass.services.async_call(
domain, service, data, blocking=False, context=context
)
return directive.response()
response = directive.response()
response.add_context_property(
{
"namespace": "Alexa.ToggleController",
"instance": instance,
"name": "toggleState",
"value": "ON",
}
)
return response
@HANDLERS.register(("Alexa.ToggleController", "TurnOff"))
@ -1061,19 +1047,29 @@ async def async_api_toggle_off(hass, config, directive, context):
service = None
data = {ATTR_ENTITY_ID: entity.entity_id}
if domain != fan.DOMAIN:
msg = "Entity does not support directive"
raise AlexaInvalidDirectiveError(msg)
# Fan Oscillating
if instance == f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}":
service = fan.SERVICE_OSCILLATE
data[fan.ATTR_OSCILLATING] = False
else:
msg = "Entity does not support directive"
raise AlexaInvalidDirectiveError(msg)
await hass.services.async_call(
domain, service, data, blocking=False, context=context
)
return directive.response()
response = directive.response()
response.add_context_property(
{
"namespace": "Alexa.ToggleController",
"instance": instance,
"name": "toggleState",
"value": "OFF",
}
)
return response
@HANDLERS.register(("Alexa.RangeController", "SetRangeValue"))
@ -1086,10 +1082,7 @@ async def async_api_set_range(hass, config, directive, context):
data = {ATTR_ENTITY_ID: entity.entity_id}
range_value = int(directive.payload["rangeValue"])
if domain != fan.DOMAIN:
msg = "Entity does not support directive"
raise AlexaInvalidDirectiveError(msg)
# Fan Speed
if instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}":
service = fan.SERVICE_SET_SPEED
speed = SPEED_FAN_MAP.get(range_value, None)
@ -1103,11 +1096,45 @@ async def async_api_set_range(hass, config, directive, context):
data[fan.ATTR_SPEED] = speed
# Cover Position
elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
if range_value == 0:
service = cover.SERVICE_CLOSE_COVER
elif range_value == 100:
service = cover.SERVICE_OPEN_COVER
else:
service = cover.SERVICE_SET_COVER_POSITION
data[cover.ATTR_POSITION] = range_value
# Cover Tilt Position
elif instance == f"{cover.DOMAIN}.{cover.ATTR_TILT_POSITION}":
if range_value == 0:
service = cover.SERVICE_CLOSE_COVER_TILT
elif range_value == 100:
service = cover.SERVICE_OPEN_COVER_TILT
else:
service = cover.SERVICE_SET_COVER_TILT_POSITION
data[cover.ATTR_POSITION] = range_value
else:
msg = "Entity does not support directive"
raise AlexaInvalidDirectiveError(msg)
await hass.services.async_call(
domain, service, data, blocking=False, context=context
)
return directive.response()
response = directive.response()
response.add_context_property(
{
"namespace": "Alexa.RangeController",
"instance": instance,
"name": "rangeValue",
"value": range_value,
}
)
return response
@HANDLERS.register(("Alexa.RangeController", "AdjustRangeValue"))
@ -1119,24 +1146,56 @@ async def async_api_adjust_range(hass, config, directive, context):
service = None
data = {ATTR_ENTITY_ID: entity.entity_id}
range_delta = int(directive.payload["rangeValueDelta"])
response_value = 0
# Fan Speed
if instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}":
service = fan.SERVICE_SET_SPEED
# adjust range
current_range = RANGE_FAN_MAP.get(entity.attributes.get(fan.ATTR_SPEED), 0)
speed = SPEED_FAN_MAP.get(max(0, range_delta + current_range), fan.SPEED_OFF)
speed = SPEED_FAN_MAP.get(
min(3, max(0, range_delta + current_range)), fan.SPEED_OFF
)
if speed == fan.SPEED_OFF:
service = fan.SERVICE_TURN_OFF
data[fan.ATTR_SPEED] = speed
data[fan.ATTR_SPEED] = response_value = speed
# Cover Position
elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
service = SERVICE_SET_COVER_POSITION
current = entity.attributes.get(cover.ATTR_POSITION)
data[cover.ATTR_POSITION] = response_value = min(
100, max(0, range_delta + current)
)
# Cover Tilt Position
elif instance == f"{cover.DOMAIN}.{cover.ATTR_TILT_POSITION}":
service = SERVICE_SET_COVER_TILT_POSITION
current = entity.attributes.get(cover.ATTR_TILT_POSITION)
data[cover.ATTR_TILT_POSITION] = response_value = min(
100, max(0, range_delta + current)
)
else:
msg = "Entity does not support directive"
raise AlexaInvalidDirectiveError(msg)
await hass.services.async_call(
domain, service, data, blocking=False, context=context
)
return directive.response()
response = directive.response()
response.add_context_property(
{
"namespace": "Alexa.RangeController",
"instance": instance,
"name": "rangeValue",
"value": response_value,
}
)
return response
@HANDLERS.register(("Alexa.ChannelController", "ChangeChannel"))

View file

@ -0,0 +1,387 @@
"""Alexa Resources and Assets."""
class AlexaGlobalCatalog:
"""The Global Alexa catalog.
https://developer.amazon.com/docs/device-apis/resources-and-assets.html#global-alexa-catalog
You can use the global Alexa catalog for pre-defined names of devices, settings, values, and units.
This catalog is localized into all the languages that Alexa supports.
You can reference the following catalog of pre-defined friendly names.
Each item in the following list is an asset identifier followed by its supported friendly names.
The first friendly name for each identifier is the one displayed in the Alexa mobile app.
"""
# Air Purifier, Air Cleaner,Clean Air Machine
DEVICE_NAME_AIR_PURIFIER = "Alexa.DeviceName.AirPurifier"
# Fan, Blower
DEVICE_NAME_FAN = "Alexa.DeviceName.Fan"
# Router, Internet Router, Network Router, Wifi Router, Net Router
DEVICE_NAME_ROUTER = "Alexa.DeviceName.Router"
# Shade, Blind, Curtain, Roller, Shutter, Drape, Awning, Window shade, Interior blind
DEVICE_NAME_SHADE = "Alexa.DeviceName.Shade"
# Shower
DEVICE_NAME_SHOWER = "Alexa.DeviceName.Shower"
# Space Heater, Portable Heater
DEVICE_NAME_SPACE_HEATER = "Alexa.DeviceName.SpaceHeater"
# Washer, Washing Machine
DEVICE_NAME_WASHER = "Alexa.DeviceName.Washer"
# 2.4G Guest Wi-Fi, 2.4G Guest Network, Guest Network 2.4G, 2G Guest Wifi
SETTING_2G_GUEST_WIFI = "Alexa.Setting.2GGuestWiFi"
# 5G Guest Wi-Fi, 5G Guest Network, Guest Network 5G, 5G Guest Wifi
SETTING_5G_GUEST_WIFI = "Alexa.Setting.5GGuestWiFi"
# Auto, Automatic, Automatic Mode, Auto Mode
SETTING_AUTO = "Alexa.Setting.Auto"
# Direction
SETTING_DIRECTION = "Alexa.Setting.Direction"
# Dry Cycle, Dry Preset, Dry Setting, Dryer Cycle, Dryer Preset, Dryer Setting
SETTING_DRY_CYCLE = "Alexa.Setting.DryCycle"
# Fan Speed, Airflow speed, Wind Speed, Air speed, Air velocity
SETTING_FAN_SPEED = "Alexa.Setting.FanSpeed"
# Guest Wi-fi, Guest Network, Guest Net
SETTING_GUEST_WIFI = "Alexa.Setting.GuestWiFi"
# Heat
SETTING_HEAT = "Alexa.Setting.Heat"
# Mode
SETTING_MODE = "Alexa.Setting.Mode"
# Night, Night Mode
SETTING_NIGHT = "Alexa.Setting.Night"
# Opening, Height, Lift, Width
SETTING_OPENING = "Alexa.Setting.Opening"
# Oscillate, Swivel, Oscillation, Spin, Back and forth
SETTING_OSCILLATE = "Alexa.Setting.Oscillate"
# Preset, Setting
SETTING_PRESET = "Alexa.Setting.Preset"
# Quiet, Quiet Mode, Noiseless, Silent
SETTING_QUIET = "Alexa.Setting.Quiet"
# Temperature, Temp
SETTING_TEMPERATURE = "Alexa.Setting.Temperature"
# Wash Cycle, Wash Preset, Wash setting
SETTING_WASH_CYCLE = "Alexa.Setting.WashCycle"
# Water Temperature, Water Temp, Water Heat
SETTING_WATER_TEMPERATURE = "Alexa.Setting.WaterTemperature"
# Handheld Shower, Shower Wand, Hand Shower
SHOWER_HAND_HELD = "Alexa.Shower.HandHeld"
# Rain Head, Overhead shower, Rain Shower, Rain Spout, Rain Faucet
SHOWER_RAIN_HEAD = "Alexa.Shower.RainHead"
# Degrees, Degree
UNIT_ANGLE_DEGREES = "Alexa.Unit.Angle.Degrees"
# Radians, Radian
UNIT_ANGLE_RADIANS = "Alexa.Unit.Angle.Radians"
# Feet, Foot
UNIT_DISTANCE_FEET = "Alexa.Unit.Distance.Feet"
# Inches, Inch
UNIT_DISTANCE_INCHES = "Alexa.Unit.Distance.Inches"
# Kilometers
UNIT_DISTANCE_KILOMETERS = "Alexa.Unit.Distance.Kilometers"
# Meters, Meter, m
UNIT_DISTANCE_METERS = "Alexa.Unit.Distance.Meters"
# Miles, Mile
UNIT_DISTANCE_MILES = "Alexa.Unit.Distance.Miles"
# Yards, Yard
UNIT_DISTANCE_YARDS = "Alexa.Unit.Distance.Yards"
# Grams, Gram, g
UNIT_MASS_GRAMS = "Alexa.Unit.Mass.Grams"
# Kilograms, Kilogram, kg
UNIT_MASS_KILOGRAMS = "Alexa.Unit.Mass.Kilograms"
# Percent
UNIT_PERCENT = "Alexa.Unit.Percent"
# Celsius, Degrees Celsius, Degrees, C, Centigrade, Degrees Centigrade
UNIT_TEMPERATURE_CELSIUS = "Alexa.Unit.Temperature.Celsius"
# Degrees, Degree
UNIT_TEMPERATURE_DEGREES = "Alexa.Unit.Temperature.Degrees"
# Fahrenheit, Degrees Fahrenheit, Degrees F, Degrees, F
UNIT_TEMPERATURE_FAHRENHEIT = "Alexa.Unit.Temperature.Fahrenheit"
# Kelvin, Degrees Kelvin, Degrees K, Degrees, K
UNIT_TEMPERATURE_KELVIN = "Alexa.Unit.Temperature.Kelvin"
# Cubic Feet, Cubic Foot
UNIT_VOLUME_CUBIC_FEET = "Alexa.Unit.Volume.CubicFeet"
# Cubic Meters, Cubic Meter, Meters Cubed
UNIT_VOLUME_CUBIC_METERS = "Alexa.Unit.Volume.CubicMeters"
# Gallons, Gallon
UNIT_VOLUME_GALLONS = "Alexa.Unit.Volume.Gallons"
# Liters, Liter, L
UNIT_VOLUME_LITERS = "Alexa.Unit.Volume.Liters"
# Pints, Pint
UNIT_VOLUME_PINTS = "Alexa.Unit.Volume.Pints"
# Quarts, Quart
UNIT_VOLUME_QUARTS = "Alexa.Unit.Volume.Quarts"
# Ounces, Ounce, oz
UNIT_WEIGHT_OUNCES = "Alexa.Unit.Weight.Ounces"
# Pounds, Pound, lbs
UNIT_WEIGHT_POUNDS = "Alexa.Unit.Weight.Pounds"
# Close
VALUE_CLOSE = "Alexa.Value.Close"
# Delicates, Delicate
VALUE_DELICATE = "Alexa.Value.Delicate"
# High
VALUE_HIGH = "Alexa.Value.High"
# Low
VALUE_LOW = "Alexa.Value.Low"
# Maximum, Max
VALUE_MAXIMUM = "Alexa.Value.Maximum"
# Medium, Mid
VALUE_MEDIUM = "Alexa.Value.Medium"
# Minimum, Min
VALUE_MINIMUM = "Alexa.Value.Minimum"
# Open
VALUE_OPEN = "Alexa.Value.Open"
# Quick Wash, Fast Wash, Wash Quickly, Speed Wash
VALUE_QUICK_WASH = "Alexa.Value.QuickWash"
class AlexaCapabilityResource:
"""Base class for Alexa capabilityResources, ModeResources, and presetResources objects.
https://developer.amazon.com/docs/device-apis/resources-and-assets.html#capability-resources
"""
def __init__(self, labels):
"""Initialize an Alexa resource."""
self._resource_labels = []
for label in labels:
self._resource_labels.append(label)
def serialize_capability_resources(self):
"""Return capabilityResources object serialized for an API response."""
return self.serialize_labels(self._resource_labels)
@staticmethod
def serialize_configuration():
"""Return ModeResources, PresetResources friendlyNames serialized for an API response."""
return []
@staticmethod
def serialize_labels(resources):
"""Return resource label objects for friendlyNames serialized for an API response."""
labels = []
for label in resources:
if label in AlexaGlobalCatalog.__dict__.values():
label = {"@type": "asset", "value": {"assetId": label}}
else:
label = {"@type": "text", "value": {"text": label, "locale": "en-US"}}
labels.append(label)
return {"friendlyNames": labels}
class AlexaModeResource(AlexaCapabilityResource):
"""Implements Alexa ModeResources.
https://developer.amazon.com/docs/device-apis/resources-and-assets.html#capability-resources
"""
def __init__(self, labels, ordered=False):
"""Initialize an Alexa modeResource."""
super().__init__(labels)
self._supported_modes = []
self._mode_ordered = ordered
def add_mode(self, value, labels):
"""Add mode to the supportedModes object."""
self._supported_modes.append({"value": value, "labels": labels})
def serialize_configuration(self):
"""Return configuration for ModeResources friendlyNames serialized for an API response."""
mode_resources = []
for mode in self._supported_modes:
result = {
"value": mode["value"],
"modeResources": self.serialize_labels(mode["labels"]),
}
mode_resources.append(result)
return {"ordered": self._mode_ordered, "supportedModes": mode_resources}
class AlexaPresetResource(AlexaCapabilityResource):
"""Implements Alexa PresetResources.
Use presetResources with RangeController to provide a set of friendlyNames for each RangeController preset.
https://developer.amazon.com/docs/device-apis/resources-and-assets.html#presetresources
"""
def __init__(self, labels, min_value, max_value, precision, unit=None):
"""Initialize an Alexa presetResource."""
super().__init__(labels)
self._presets = []
self._minimum_value = int(min_value)
self._maximum_value = int(max_value)
self._precision = int(precision)
self._unit_of_measure = None
if unit in AlexaGlobalCatalog.__dict__.values():
self._unit_of_measure = unit
def add_preset(self, value, labels):
"""Add preset to configuration presets array."""
self._presets.append({"value": value, "labels": labels})
def serialize_configuration(self):
"""Return configuration for PresetResources friendlyNames serialized for an API response."""
configuration = {
"supportedRange": {
"minimumValue": self._minimum_value,
"maximumValue": self._maximum_value,
"precision": self._precision,
}
}
if self._unit_of_measure:
configuration["unitOfMeasure"] = self._unit_of_measure
if self._presets:
preset_resources = []
for preset in self._presets:
preset_resources.append(
{
"rangeValue": preset["value"],
"presetResources": self.serialize_labels(preset["labels"]),
}
)
configuration["presets"] = preset_resources
return configuration
class AlexaSemantics:
"""Class for Alexa Semantics Object.
You can optionally enable additional utterances by using semantics. When you use semantics,
you manually map the phrases "open", "close", "raise", and "lower" to directives.
Semantics is supported for the following interfaces only: ModeController, RangeController, and ToggleController.
https://developer.amazon.com/docs/device-apis/alexa-discovery.html#semantics-object
"""
MAPPINGS_ACTION = "actionMappings"
MAPPINGS_STATE = "stateMappings"
ACTIONS_TO_DIRECTIVE = "ActionsToDirective"
STATES_TO_VALUE = "StatesToValue"
STATES_TO_RANGE = "StatesToRange"
ACTION_CLOSE = "Alexa.Actions.Close"
ACTION_LOWER = "Alexa.Actions.Lower"
ACTION_OPEN = "Alexa.Actions.Open"
ACTION_RAISE = "Alexa.Actions.Raise"
STATES_OPEN = "Alexa.States.Open"
STATES_CLOSED = "Alexa.States.Closed"
DIRECTIVE_RANGE_SET_VALUE = "SetRangeValue"
DIRECTIVE_RANGE_ADJUST_VALUE = "AdjustRangeValue"
DIRECTIVE_TOGGLE_TURN_ON = "TurnOn"
DIRECTIVE_TOGGLE_TURN_OFF = "TurnOff"
DIRECTIVE_MODE_SET_MODE = "SetMode"
DIRECTIVE_MODE_ADJUST_MODE = "AdjustMode"
def __init__(self):
"""Initialize an Alexa modeResource."""
self._action_mappings = []
self._state_mappings = []
def _add_action_mapping(self, semantics):
"""Add action mapping between actions and interface directives."""
self._action_mappings.append(semantics)
def _add_state_mapping(self, semantics):
"""Add state mapping between states and interface directives."""
self._state_mappings.append(semantics)
def add_states_to_value(self, states, value):
"""Add StatesToValue stateMappings."""
self._add_state_mapping(
{"@type": self.STATES_TO_VALUE, "states": states, "value": value}
)
def add_states_to_range(self, states, min_value, max_value):
"""Add StatesToRange stateMappings."""
self._add_state_mapping(
{
"@type": self.STATES_TO_RANGE,
"states": states,
"range": {"minimumValue": min_value, "maximumValue": max_value},
}
)
def add_action_to_directive(self, actions, directive, payload):
"""Add ActionsToDirective actionMappings."""
self._add_action_mapping(
{
"@type": self.ACTIONS_TO_DIRECTIVE,
"actions": actions,
"directive": {"name": directive, "payload": payload},
}
)
def serialize_semantics(self):
"""Return semantics object serialized for an API response."""
semantics = {}
if self._action_mappings:
semantics[self.MAPPINGS_ACTION] = self._action_mappings
if self._state_mappings:
semantics[self.MAPPINGS_STATE] = self._state_mappings
return semantics

View file

@ -411,14 +411,14 @@ async def test_report_fan_direction(hass):
properties.assert_not_has_property("Alexa.ModeController", "mode")
properties = await reported_properties(hass, "fan.reverse")
properties.assert_equal("Alexa.ModeController", "mode", "reverse")
properties.assert_equal("Alexa.ModeController", "mode", "direction.reverse")
properties = await reported_properties(hass, "fan.forward")
properties.assert_equal("Alexa.ModeController", "mode", "forward")
properties.assert_equal("Alexa.ModeController", "mode", "direction.forward")
async def test_report_cover_percentage_state(hass):
"""Test PercentageController reports cover percentage correctly."""
async def test_report_cover_range_value(hass):
"""Test RangeController reports cover position correctly."""
hass.states.async_set(
"cover.fully_open",
"open",
@ -448,13 +448,13 @@ async def test_report_cover_percentage_state(hass):
)
properties = await reported_properties(hass, "cover.fully_open")
properties.assert_equal("Alexa.PercentageController", "percentage", 100)
properties.assert_equal("Alexa.RangeController", "rangeValue", 100)
properties = await reported_properties(hass, "cover.half_open")
properties.assert_equal("Alexa.PercentageController", "percentage", 50)
properties.assert_equal("Alexa.RangeController", "rangeValue", 50)
properties = await reported_properties(hass, "cover.closed")
properties.assert_equal("Alexa.PercentageController", "percentage", 0)
properties.assert_equal("Alexa.RangeController", "rangeValue", 0)
async def test_report_climate_state(hass):

View file

@ -126,10 +126,12 @@ async def discovery_test(device, hass, expected_endpoints=1):
return None
def get_capability(capabilities, capability_name):
def get_capability(capabilities, capability_name, instance=None):
"""Search a set of capabilities for a specific one."""
for capability in capabilities:
if capability["interface"] == capability_name:
if instance and capability["instance"] == instance:
return capability
elif capability["interface"] == capability_name:
return capability
return None
@ -420,9 +422,29 @@ async def test_variable_fan(hass):
)
assert call.data["speed"] == "medium"
call, _ = await assert_request_calls_service(
"Alexa.PercentageController",
"SetPercentage",
"fan#test_2",
"fan.set_speed",
hass,
payload={"percentage": "33"},
)
assert call.data["speed"] == "low"
call, _ = await assert_request_calls_service(
"Alexa.PercentageController",
"SetPercentage",
"fan#test_2",
"fan.set_speed",
hass,
payload={"percentage": "100"},
)
assert call.data["speed"] == "high"
await assert_percentage_changes(
hass,
[("high", "-5"), ("off", "5"), ("low", "-80")],
[("high", "-5"), ("off", "5"), ("low", "-80"), ("medium", "-34")],
"Alexa.PercentageController",
"AdjustPercentage",
"fan#test_2",
@ -431,6 +453,16 @@ async def test_variable_fan(hass):
"speed",
)
call, _ = await assert_request_calls_service(
"Alexa.PowerLevelController",
"SetPowerLevel",
"fan#test_2",
"fan.set_speed",
hass,
payload={"powerLevel": "20"},
)
assert call.data["speed"] == "low"
call, _ = await assert_request_calls_service(
"Alexa.PowerLevelController",
"SetPowerLevel",
@ -441,6 +473,16 @@ async def test_variable_fan(hass):
)
assert call.data["speed"] == "medium"
call, _ = await assert_request_calls_service(
"Alexa.PowerLevelController",
"SetPowerLevel",
"fan#test_2",
"fan.set_speed",
hass,
payload={"powerLevel": "99"},
)
assert call.data["speed"] == "high"
await assert_percentage_changes(
hass,
[("high", "-5"), ("medium", "-50"), ("low", "-80")],
@ -1333,51 +1375,106 @@ async def test_group(hass):
)
async def test_cover(hass):
"""Test cover discovery."""
async def test_cover_position_range(hass):
"""Test cover discovery and position using rangeController."""
device = (
"cover.test",
"off",
{"friendly_name": "Test cover", "supported_features": 255, "position": 30},
"cover.test_range",
"open",
{
"friendly_name": "Test cover range",
"device_class": "blind",
"supported_features": 7,
"position": 30,
},
)
appliance = await discovery_test(device, hass)
assert appliance["endpointId"] == "cover#test"
assert appliance["displayCategories"][0] == "OTHER"
assert appliance["friendlyName"] == "Test cover"
assert appliance["endpointId"] == "cover#test_range"
assert appliance["displayCategories"][0] == "INTERIOR_BLIND"
assert appliance["friendlyName"] == "Test cover range"
assert_endpoint_capabilities(
appliance,
"Alexa.ModeController",
"Alexa.PercentageController",
"Alexa.PowerController",
"Alexa.EndpointHealth",
"Alexa",
capabilities = assert_endpoint_capabilities(
appliance, "Alexa.RangeController", "Alexa.EndpointHealth", "Alexa"
)
await assert_power_controller_works(
"cover#test", "cover.open_cover", "cover.close_cover", hass
)
range_capability = get_capability(capabilities, "Alexa.RangeController")
assert range_capability is not None
assert range_capability["instance"] == "cover.position"
properties = range_capability["properties"]
assert properties["nonControllable"] is False
assert {"name": "rangeValue"} in properties["supported"]
capability_resources = range_capability["capabilityResources"]
assert capability_resources is not None
assert {
"@type": "text",
"value": {"text": "Position", "locale": "en-US"},
} in capability_resources["friendlyNames"]
assert {
"@type": "asset",
"value": {"assetId": "Alexa.Setting.Opening"},
} in capability_resources["friendlyNames"]
configuration = range_capability["configuration"]
assert configuration is not None
assert configuration["unitOfMeasure"] == "Alexa.Unit.Percent"
supported_range = configuration["supportedRange"]
assert supported_range["minimumValue"] == 0
assert supported_range["maximumValue"] == 100
assert supported_range["precision"] == 1
call, _ = await assert_request_calls_service(
"Alexa.PercentageController",
"SetPercentage",
"cover#test",
"Alexa.RangeController",
"SetRangeValue",
"cover#test_range",
"cover.set_cover_position",
hass,
payload={"percentage": "50"},
payload={"rangeValue": "50"},
instance="cover.position",
)
assert call.data["position"] == 50
await assert_percentage_changes(
call, msg = await assert_request_calls_service(
"Alexa.RangeController",
"SetRangeValue",
"cover#test_range",
"cover.close_cover",
hass,
[(25, "-5"), (35, "5"), (0, "-80")],
"Alexa.PercentageController",
"AdjustPercentage",
"cover#test",
"percentageDelta",
payload={"rangeValue": "0"},
instance="cover.position",
)
properties = msg["context"]["properties"][0]
assert properties["name"] == "rangeValue"
assert properties["namespace"] == "Alexa.RangeController"
assert properties["value"] == 0
call, msg = await assert_request_calls_service(
"Alexa.RangeController",
"SetRangeValue",
"cover#test_range",
"cover.open_cover",
hass,
payload={"rangeValue": "100"},
instance="cover.position",
)
properties = msg["context"]["properties"][0]
assert properties["name"] == "rangeValue"
assert properties["namespace"] == "Alexa.RangeController"
assert properties["value"] == 100
await assert_range_changes(
hass,
[(25, "-5"), (35, "5"), (0, "-99"), (100, "99")],
"Alexa.RangeController",
"AdjustRangeValue",
"cover#test_range",
False,
"cover.set_cover_position",
"position",
instance="cover.position",
)
@ -2292,26 +2389,25 @@ async def test_mode_unsupported_domain(hass):
assert msg["payload"]["type"] == "INVALID_DIRECTIVE"
async def test_cover_position(hass):
"""Test cover position mode discovery."""
async def test_cover_position_mode(hass):
"""Test cover discovery and position using modeController."""
device = (
"cover.test",
"off",
{"friendly_name": "Test cover", "supported_features": 255, "position": 30},
"cover.test_mode",
"open",
{
"friendly_name": "Test cover mode",
"device_class": "blind",
"supported_features": 3,
},
)
appliance = await discovery_test(device, hass)
assert appliance["endpointId"] == "cover#test"
assert appliance["displayCategories"][0] == "OTHER"
assert appliance["friendlyName"] == "Test cover"
assert appliance["endpointId"] == "cover#test_mode"
assert appliance["displayCategories"][0] == "INTERIOR_BLIND"
assert appliance["friendlyName"] == "Test cover mode"
capabilities = assert_endpoint_capabilities(
appliance,
"Alexa",
"Alexa.ModeController",
"Alexa.PercentageController",
"Alexa.PowerController",
"Alexa.EndpointHealth",
appliance, "Alexa", "Alexa.ModeController", "Alexa.EndpointHealth"
)
mode_capability = get_capability(capabilities, "Alexa.ModeController")
@ -2324,9 +2420,14 @@ async def test_cover_position(hass):
capability_resources = mode_capability["capabilityResources"]
assert capability_resources is not None
assert {
"@type": "text",
"value": {"text": "Position", "locale": "en-US"},
} in capability_resources["friendlyNames"]
assert {
"@type": "asset",
"value": {"assetId": "Alexa.Setting.Mode"},
"value": {"assetId": "Alexa.Setting.Opening"},
} in capability_resources["friendlyNames"]
configuration = mode_capability["configuration"]
@ -2339,10 +2440,7 @@ async def test_cover_position(hass):
"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"}},
{"@type": "asset", "value": {"assetId": "Alexa.Value.Open"}}
]
},
} in supported_modes
@ -2350,19 +2448,24 @@ async def test_cover_position(hass):
"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"}},
{"@type": "asset", "value": {"assetId": "Alexa.Value.Close"}}
]
},
} in supported_modes
semantics = mode_capability["semantics"]
assert semantics is not None
action_mappings = semantics["actionMappings"]
assert action_mappings is not None
state_mappings = semantics["stateMappings"]
assert state_mappings is not None
call, msg = await assert_request_calls_service(
"Alexa.ModeController",
"SetMode",
"cover#test",
"cover#test_mode",
"cover.close_cover",
hass,
payload={"mode": "position.closed"},
@ -2376,7 +2479,7 @@ async def test_cover_position(hass):
call, msg = await assert_request_calls_service(
"Alexa.ModeController",
"SetMode",
"cover#test",
"cover#test_mode",
"cover.open_cover",
hass,
payload={"mode": "position.open"},
@ -2387,6 +2490,20 @@ async def test_cover_position(hass):
assert properties["namespace"] == "Alexa.ModeController"
assert properties["value"] == "position.open"
call, msg = await assert_request_calls_service(
"Alexa.ModeController",
"SetMode",
"cover#test_mode",
"cover.stop_cover",
hass,
payload={"mode": "position.custom"},
instance="cover.position",
)
properties = msg["context"]["properties"][0]
assert properties["name"] == "mode"
assert properties["namespace"] == "Alexa.ModeController"
assert properties["value"] == "position.custom"
async def test_image_processing(hass):
"""Test image_processing discovery as event detection."""
@ -2467,3 +2584,159 @@ async def test_presence_sensor(hass):
assert properties["proactivelyReported"] is True
assert not properties["retrievable"]
assert {"name": "humanPresenceDetectionState"} in properties["supported"]
async def test_cover_tilt_position_range(hass):
"""Test cover discovery and tilt position using rangeController."""
device = (
"cover.test_tilt_range",
"open",
{
"friendly_name": "Test cover tilt range",
"device_class": "blind",
"supported_features": 240,
"tilt_position": 30,
},
)
appliance = await discovery_test(device, hass)
assert appliance["endpointId"] == "cover#test_tilt_range"
assert appliance["displayCategories"][0] == "INTERIOR_BLIND"
assert appliance["friendlyName"] == "Test cover tilt range"
capabilities = assert_endpoint_capabilities(
appliance, "Alexa.RangeController", "Alexa.EndpointHealth", "Alexa"
)
range_capability = get_capability(capabilities, "Alexa.RangeController")
assert range_capability is not None
assert range_capability["instance"] == "cover.tilt_position"
semantics = range_capability["semantics"]
assert semantics is not None
action_mappings = semantics["actionMappings"]
assert action_mappings is not None
state_mappings = semantics["stateMappings"]
assert state_mappings is not None
call, _ = await assert_request_calls_service(
"Alexa.RangeController",
"SetRangeValue",
"cover#test_tilt_range",
"cover.set_cover_tilt_position",
hass,
payload={"rangeValue": "50"},
instance="cover.tilt_position",
)
assert call.data["position"] == 50
call, msg = await assert_request_calls_service(
"Alexa.RangeController",
"SetRangeValue",
"cover#test_tilt_range",
"cover.close_cover_tilt",
hass,
payload={"rangeValue": "0"},
instance="cover.tilt_position",
)
properties = msg["context"]["properties"][0]
assert properties["name"] == "rangeValue"
assert properties["namespace"] == "Alexa.RangeController"
assert properties["value"] == 0
call, msg = await assert_request_calls_service(
"Alexa.RangeController",
"SetRangeValue",
"cover#test_tilt_range",
"cover.open_cover_tilt",
hass,
payload={"rangeValue": "100"},
instance="cover.tilt_position",
)
properties = msg["context"]["properties"][0]
assert properties["name"] == "rangeValue"
assert properties["namespace"] == "Alexa.RangeController"
assert properties["value"] == 100
await assert_range_changes(
hass,
[(25, "-5"), (35, "5"), (0, "-99"), (100, "99")],
"Alexa.RangeController",
"AdjustRangeValue",
"cover#test_tilt_range",
False,
"cover.set_cover_tilt_position",
"tilt_position",
instance="cover.tilt_position",
)
async def test_cover_semantics(hass):
"""Test cover discovery and semantics."""
device = (
"cover.test_semantics",
"open",
{
"friendly_name": "Test cover semantics",
"device_class": "blind",
"supported_features": 255,
"position": 30,
"tilt_position": 30,
},
)
appliance = await discovery_test(device, hass)
assert appliance["endpointId"] == "cover#test_semantics"
assert appliance["displayCategories"][0] == "INTERIOR_BLIND"
assert appliance["friendlyName"] == "Test cover semantics"
capabilities = assert_endpoint_capabilities(
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
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
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