From 5baaa852ddb6777de839bda12ff0b7022c74beb0 Mon Sep 17 00:00:00 2001 From: ochlocracy <5885236+ochlocracy@users.noreply.github.com> Date: Thu, 19 Dec 2019 06:44:17 -0500 Subject: [PATCH] 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. --- .../components/alexa/capabilities.py | 394 +++++++++--------- homeassistant/components/alexa/const.py | 157 ------- homeassistant/components/alexa/entities.py | 76 +++- homeassistant/components/alexa/handlers.py | 195 ++++++--- homeassistant/components/alexa/resources.py | 387 +++++++++++++++++ tests/components/alexa/test_capabilities.py | 14 +- tests/components/alexa/test_smart_home.py | 387 ++++++++++++++--- 7 files changed, 1123 insertions(+), 487 deletions(-) create mode 100644 homeassistant/components/alexa/resources.py diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index b5ffb1ef7e6..938101a7500 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -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): diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py index 2c62e1a485a..f1a86859da9 100644 --- a/homeassistant/components/alexa/const.py +++ b/homeassistant/components/alexa/const.py @@ -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. diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 017686df607..2a3355434a3 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -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}" diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index efb4f59514d..b5603af7402 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -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")) diff --git a/homeassistant/components/alexa/resources.py b/homeassistant/components/alexa/resources.py new file mode 100644 index 00000000000..061005252dc --- /dev/null +++ b/homeassistant/components/alexa/resources.py @@ -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 diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index ab9c375103a..9c086e1fc50 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -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): diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 25c8f2a864f..4187c4a2c4f 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -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