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