From da094e09fa270b883abbe1f67256e51254f9dce3 Mon Sep 17 00:00:00 2001 From: ochlocracy <5885236+ochlocracy@users.noreply.github.com> Date: Wed, 23 Oct 2019 01:01:03 -0400 Subject: [PATCH] Implement ToggleController, RangeController, and ModeController in alexa (#27302) * Implement AlexaToggleController, AlexaRangeController, and AlexaModeController interfaces. * Implement AlexaToggleController, AlexaRangeController, and AlexaModeController interfaces. * Unkerfuffled comments to please the pydocstyle gods. * Unkerfuffled comments in Tests to please the pydocstyle gods. * Added additional test for more coverage. * Removed OSCILLATING property check from from ModeController. * Added capability report tests for ModeController, ToggleController, RangeController, PowerLevelController. * Update homeassistant/components/alexa/capabilities.py Co-Authored-By: Paulus Schoutsen * Update homeassistant/components/alexa/capabilities.py Co-Authored-By: Paulus Schoutsen * Corrected mis-spelling of AlexaCapability class. * Changed instance from method to property in AlexaCapability class. * Refactored to add {entity.domain}.{entity.attribute} to the instance name. * Improved type handling for configuration object. Added additional test for configuration object. * Added Tests for unsupported domains for ModeController and RangeController * Made changes to improve future scaling for other domains. * Split fan range to speed maps into multiple constants. --- .../components/alexa/capabilities.py | 375 ++++++++++++++++-- homeassistant/components/alexa/const.py | 172 +++++++- homeassistant/components/alexa/entities.py | 16 + homeassistant/components/alexa/errors.py | 14 + homeassistant/components/alexa/handlers.py | 194 ++++++++- homeassistant/components/alexa/messages.py | 5 +- tests/components/alexa/__init__.py | 11 +- tests/components/alexa/test_capabilities.py | 69 +++- tests/components/alexa/test_smart_home.py | 339 +++++++++++++++- tests/components/alexa/test_state_report.py | 48 +++ 10 files changed, 1190 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 7be3188fea1..f4d93026649 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -23,19 +23,20 @@ import homeassistant.util.color as color_util import homeassistant.util.dt as dt_util from .const import ( + Catalog, API_TEMP_UNITS, API_THERMOSTAT_MODES, API_THERMOSTAT_PRESETS, DATE_FORMAT, PERCENTAGE_FAN_MAP, + RANGE_FAN_MAP, ) from .errors import UnsupportedProperty - _LOGGER = logging.getLogger(__name__) -class AlexaCapibility: +class AlexaCapability: """Base class for Alexa capability interfaces. The Smart Home Skills API defines a number of "capability interfaces", @@ -45,9 +46,10 @@ class AlexaCapibility: https://developer.amazon.com/docs/device-apis/message-guide.html """ - def __init__(self, entity): - """Initialize an Alexa capibility.""" + def __init__(self, entity, instance=None): + """Initialize an Alexa capability.""" self.entity = entity + self.instance = instance def name(self): """Return the Alexa API name of this interface.""" @@ -68,6 +70,11 @@ class AlexaCapibility: """Return True if properties can be retrieved.""" return False + @staticmethod + def properties_non_controllable(): + """Return True if non controllable.""" + return None + @staticmethod def get_property(name): """Read and return a property. @@ -84,9 +91,14 @@ class AlexaCapibility: """Applicable only to scenes.""" return None + @staticmethod + def capability_resources(): + """Applicable to ToggleController, RangeController, and ModeController interfaces.""" + return [] + @staticmethod def configuration(): - """Applicable only to security control panel.""" + """Return the Configuration object.""" return [] def serialize_discovery(self): @@ -102,15 +114,29 @@ class AlexaCapibility: }, } + # 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() + if capability_resources: + result["capabilityResources"] = capability_resources + configuration = self.configuration() if configuration: result["configuration"] = configuration + # pylint: disable=assignment-from-none + instance = self.instance + if instance is not None: + result["instance"] = instance + return result def serialize_properties(self): @@ -120,16 +146,51 @@ class AlexaCapibility: # pylint: disable=assignment-from-no-return prop_value = self.get_property(prop_name) if prop_value is not None: - yield { + result = { "name": prop_name, "namespace": self.name(), "value": prop_value, "timeOfSample": dt_util.utcnow().strftime(DATE_FORMAT), "uncertaintyInMilliseconds": 0, } + instance = self.instance + if instance is not None: + result["instance"] = instance + + 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 AlexaEndpointHealth(AlexaCapibility): +class AlexaEndpointHealth(AlexaCapability): """Implements Alexa.EndpointHealth. https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#report-state-when-alexa-requests-it @@ -166,7 +227,7 @@ class AlexaEndpointHealth(AlexaCapibility): return {"value": "OK"} -class AlexaPowerController(AlexaCapibility): +class AlexaPowerController(AlexaCapability): """Implements Alexa.PowerController. https://developer.amazon.com/docs/device-apis/alexa-powercontroller.html @@ -202,7 +263,7 @@ class AlexaPowerController(AlexaCapibility): return "ON" if is_on else "OFF" -class AlexaLockController(AlexaCapibility): +class AlexaLockController(AlexaCapability): """Implements Alexa.LockController. https://developer.amazon.com/docs/device-apis/alexa-lockcontroller.html @@ -236,7 +297,7 @@ class AlexaLockController(AlexaCapibility): return "JAMMED" -class AlexaSceneController(AlexaCapibility): +class AlexaSceneController(AlexaCapability): """Implements Alexa.SceneController. https://developer.amazon.com/docs/device-apis/alexa-scenecontroller.html @@ -252,7 +313,7 @@ class AlexaSceneController(AlexaCapibility): return "Alexa.SceneController" -class AlexaBrightnessController(AlexaCapibility): +class AlexaBrightnessController(AlexaCapability): """Implements Alexa.BrightnessController. https://developer.amazon.com/docs/device-apis/alexa-brightnesscontroller.html @@ -283,7 +344,7 @@ class AlexaBrightnessController(AlexaCapibility): return 0 -class AlexaColorController(AlexaCapibility): +class AlexaColorController(AlexaCapability): """Implements Alexa.ColorController. https://developer.amazon.com/docs/device-apis/alexa-colorcontroller.html @@ -315,7 +376,7 @@ class AlexaColorController(AlexaCapibility): } -class AlexaColorTemperatureController(AlexaCapibility): +class AlexaColorTemperatureController(AlexaCapability): """Implements Alexa.ColorTemperatureController. https://developer.amazon.com/docs/device-apis/alexa-colortemperaturecontroller.html @@ -344,7 +405,7 @@ class AlexaColorTemperatureController(AlexaCapibility): return None -class AlexaPercentageController(AlexaCapibility): +class AlexaPercentageController(AlexaCapability): """Implements Alexa.PercentageController. https://developer.amazon.com/docs/device-apis/alexa-percentagecontroller.html @@ -378,7 +439,7 @@ class AlexaPercentageController(AlexaCapibility): return 0 -class AlexaSpeaker(AlexaCapibility): +class AlexaSpeaker(AlexaCapability): """Implements Alexa.Speaker. https://developer.amazon.com/docs/device-apis/alexa-speaker.html @@ -389,7 +450,7 @@ class AlexaSpeaker(AlexaCapibility): return "Alexa.Speaker" -class AlexaStepSpeaker(AlexaCapibility): +class AlexaStepSpeaker(AlexaCapability): """Implements Alexa.StepSpeaker. https://developer.amazon.com/docs/device-apis/alexa-stepspeaker.html @@ -400,7 +461,7 @@ class AlexaStepSpeaker(AlexaCapibility): return "Alexa.StepSpeaker" -class AlexaPlaybackController(AlexaCapibility): +class AlexaPlaybackController(AlexaCapability): """Implements Alexa.PlaybackController. https://developer.amazon.com/docs/device-apis/alexa-playbackcontroller.html @@ -411,7 +472,7 @@ class AlexaPlaybackController(AlexaCapibility): return "Alexa.PlaybackController" -class AlexaInputController(AlexaCapibility): +class AlexaInputController(AlexaCapability): """Implements Alexa.InputController. https://developer.amazon.com/docs/device-apis/alexa-inputcontroller.html @@ -422,7 +483,7 @@ class AlexaInputController(AlexaCapibility): return "Alexa.InputController" -class AlexaTemperatureSensor(AlexaCapibility): +class AlexaTemperatureSensor(AlexaCapability): """Implements Alexa.TemperatureSensor. https://developer.amazon.com/docs/device-apis/alexa-temperaturesensor.html @@ -472,7 +533,7 @@ class AlexaTemperatureSensor(AlexaCapibility): return {"value": temp, "scale": API_TEMP_UNITS[unit]} -class AlexaContactSensor(AlexaCapibility): +class AlexaContactSensor(AlexaCapability): """Implements Alexa.ContactSensor. The Alexa.ContactSensor interface describes the properties and events used @@ -514,7 +575,7 @@ class AlexaContactSensor(AlexaCapibility): return "NOT_DETECTED" -class AlexaMotionSensor(AlexaCapibility): +class AlexaMotionSensor(AlexaCapability): """Implements Alexa.MotionSensor. https://developer.amazon.com/docs/device-apis/alexa-motionsensor.html @@ -551,7 +612,7 @@ class AlexaMotionSensor(AlexaCapibility): return "NOT_DETECTED" -class AlexaThermostatController(AlexaCapibility): +class AlexaThermostatController(AlexaCapability): """Implements Alexa.ThermostatController. https://developer.amazon.com/docs/device-apis/alexa-thermostatcontroller.html @@ -631,7 +692,7 @@ class AlexaThermostatController(AlexaCapibility): return {"value": temp, "scale": API_TEMP_UNITS[unit]} -class AlexaPowerLevelController(AlexaCapibility): +class AlexaPowerLevelController(AlexaCapability): """Implements Alexa.PowerLevelController. https://developer.amazon.com/docs/device-apis/alexa-powerlevelcontroller.html @@ -666,7 +727,7 @@ class AlexaPowerLevelController(AlexaCapibility): return None -class AlexaSecurityPanelController(AlexaCapibility): +class AlexaSecurityPanelController(AlexaCapability): """Implements Alexa.SecurityPanelController. https://developer.amazon.com/docs/device-apis/alexa-securitypanelcontroller.html @@ -710,9 +771,271 @@ class AlexaSecurityPanelController(AlexaCapibility): return "DISARMED" def configuration(self): - """Return supported authorization types.""" + """Return configuration object with supported authorization types.""" code_format = self.entity.attributes.get(ATTR_CODE_FORMAT) if code_format == FORMAT_NUMBER: return {"supportedAuthorizationTypes": [{"type": "FOUR_DIGIT_PIN"}]} - return [] + return None + + +class AlexaModeController(AlexaCapability): + """Implements Alexa.ModeController. + + https://developer.amazon.com/docs/device-apis/alexa-modecontroller.html + """ + + def __init__(self, entity, instance, non_controllable=False): + """Initialize the entity.""" + super().__init__(entity, instance) + self.properties_non_controllable = lambda: non_controllable + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.ModeController" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "mode"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + + def get_property(self, name): + """Read and return a property.""" + if name != "mode": + raise UnsupportedProperty(name) + + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}": + return self.entity.attributes.get(fan.ATTR_DIRECTION) + + return None + + def configuration(self): + """Return configuration with modeResources.""" + return self.serialize_mode_resources() + + def capability_resources(self): + """Return capabilityResources object.""" + capability_resources = [] + + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}": + capability_resources = [ + {"type": Catalog.LABEL_ASSET, "value": Catalog.SETTING_DIRECTION} + ] + + return capability_resources + + 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} + ], + }, + ], + } + + 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} + + +class AlexaRangeController(AlexaCapability): + """Implements Alexa.RangeController. + + https://developer.amazon.com/docs/device-apis/alexa-rangecontroller.html + """ + + def __init__(self, entity, instance, non_controllable=False): + """Initialize the entity.""" + super().__init__(entity, instance) + self.properties_non_controllable = lambda: non_controllable + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.RangeController" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "rangeValue"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + 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 != "rangeValue": + raise UnsupportedProperty(name) + + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}": + speed = self.entity.attributes.get(fan.ATTR_SPEED) + return RANGE_FAN_MAP.get(speed, 0) + + return None + + def configuration(self): + """Return configuration with presetResources.""" + return self.serialize_preset_resources() + + def capability_resources(self): + """Return capabilityResources object.""" + capability_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"]) + }, + } + ) + + return { + "supportedRange": { + "minimumValue": resources["minimumValue"], + "maximumValue": resources["maximumValue"], + "precision": resources["precision"], + }, + "presets": preset_resources, + } + + +class AlexaToggleController(AlexaCapability): + """Implements Alexa.ToggleController. + + https://developer.amazon.com/docs/device-apis/alexa-togglecontroller.html + """ + + def __init__(self, entity, instance, non_controllable=False): + """Initialize the entity.""" + super().__init__(entity, instance) + self.properties_non_controllable = lambda: non_controllable + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.ToggleController" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "toggleState"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + 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 != "toggleState": + raise UnsupportedProperty(name) + + 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" + + return None + + def capability_resources(self): + """Return capabilityResources object.""" + capability_resources = [] + + 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"}, + ] + + return capability_resources diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py index cd0cb85a0a5..8d1f0ac95a5 100644 --- a/homeassistant/components/alexa/const.py +++ b/homeassistant/components/alexa/const.py @@ -5,7 +5,6 @@ from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.components.climate import const as climate from homeassistant.components import fan - DOMAIN = "alexa" # Flash briefing constants @@ -69,6 +68,20 @@ PERCENTAGE_FAN_MAP = { fan.SPEED_HIGH: 100, } +RANGE_FAN_MAP = { + fan.SPEED_OFF: 0, + fan.SPEED_LOW: 1, + fan.SPEED_MEDIUM: 2, + fan.SPEED_HIGH: 3, +} + +SPEED_FAN_MAP = { + 0: fan.SPEED_OFF, + 1: fan.SPEED_LOW, + 2: fan.SPEED_MEDIUM, + 3: fan.SPEED_HIGH, +} + class Cause: """Possible causes for property changes. @@ -101,3 +114,160 @@ class Cause: # Indicates that the event was caused by a voice interaction with Alexa. # For example a user speaking to their Echo device. 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" diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 0f07e525fa9..dd640aed0a6 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -40,17 +40,20 @@ from .capabilities import ( AlexaEndpointHealth, AlexaInputController, AlexaLockController, + AlexaModeController, AlexaMotionSensor, AlexaPercentageController, AlexaPlaybackController, AlexaPowerController, AlexaPowerLevelController, + AlexaRangeController, AlexaSceneController, AlexaSecurityPanelController, AlexaSpeaker, AlexaStepSpeaker, AlexaTemperatureSensor, AlexaThermostatController, + AlexaToggleController, ) ENTITY_ADAPTERS = Registry() @@ -348,6 +351,19 @@ class FanCapabilities(AlexaEntity): if supported & fan.SUPPORT_SET_SPEED: yield AlexaPercentageController(self.entity) yield AlexaPowerLevelController(self.entity) + 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}" + ) + if supported & fan.SUPPORT_DIRECTION: + yield AlexaModeController( + self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}" + ) + yield AlexaEndpointHealth(self.hass, self.entity) diff --git a/homeassistant/components/alexa/errors.py b/homeassistant/components/alexa/errors.py index 8e32ed9c7ee..b0600313fc2 100644 --- a/homeassistant/components/alexa/errors.py +++ b/homeassistant/components/alexa/errors.py @@ -97,3 +97,17 @@ class AlexaSecurityPanelAuthorizationRequired(AlexaError): namespace = "Alexa.SecurityPanelController" error_type = "AUTHORIZATION_REQUIRED" + + +class AlexaAlreadyInOperationError(AlexaError): + """Class to represent AlreadyInOperation errors.""" + + namespace = "Alexa" + error_type = "ALREADY_IN_OPERATION" + + +class AlexaInvalidDirectiveError(AlexaError): + """Class to represent InvalidDirective errors.""" + + namespace = "Alexa" + error_type = "INVALID_DIRECTIVE" diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 139defe8313..64feacb92f5 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -36,9 +36,18 @@ import homeassistant.util.dt as dt_util from homeassistant.util.decorator import Registry from homeassistant.util.temperature import convert as convert_temperature -from .const import API_TEMP_UNITS, API_THERMOSTAT_MODES, API_THERMOSTAT_PRESETS, Cause +from .const import ( + API_TEMP_UNITS, + API_THERMOSTAT_MODES, + API_THERMOSTAT_PRESETS, + Cause, + PERCENTAGE_FAN_MAP, + RANGE_FAN_MAP, + SPEED_FAN_MAP, +) from .entities import async_get_entities from .errors import ( + AlexaInvalidDirectiveError, AlexaInvalidValueError, AlexaSecurityPanelAuthorizationRequired, AlexaSecurityPanelUnauthorizedError, @@ -356,15 +365,7 @@ async def async_api_adjust_percentage(hass, config, directive, context): if entity.domain == fan.DOMAIN: service = fan.SERVICE_SET_SPEED speed = entity.attributes.get(fan.ATTR_SPEED) - - if speed == "off": - current = 0 - elif speed == "low": - current = 33 - elif speed == "medium": - current = 66 - elif speed == "high": - current = 100 + current = PERCENTAGE_FAN_MAP.get(speed, 100) # set percentage percentage = max(0, percentage_delta + current) @@ -827,20 +828,11 @@ async def async_api_adjust_power_level(hass, config, directive, context): percentage_delta = int(directive.payload["powerLevelDelta"]) service = None data = {ATTR_ENTITY_ID: entity.entity_id} - current = 0 if entity.domain == fan.DOMAIN: service = fan.SERVICE_SET_SPEED speed = entity.attributes.get(fan.ATTR_SPEED) - - if speed == "off": - current = 0 - elif speed == "low": - current = 33 - elif speed == "medium": - current = 66 - else: - current = 100 + current = PERCENTAGE_FAN_MAP.get(speed, 100) # set percentage percentage = max(0, percentage_delta + current) @@ -928,3 +920,165 @@ async def async_api_disarm(hass, config, directive, context): ) return response + + +@HANDLERS.register(("Alexa.ModeController", "SetMode")) +async def async_api_set_mode(hass, config, directive, context): + """Process a next request.""" + entity = directive.entity + instance = directive.instance + domain = entity.domain + service = None + data = {ATTR_ENTITY_ID: entity.entity_id} + mode = directive.payload["mode"] + + if domain != fan.DOMAIN: + msg = "Entity does not support directive" + raise AlexaInvalidDirectiveError(msg) + + if instance == f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}": + mode, direction = mode.split(".") + if direction in [fan.DIRECTION_REVERSE, fan.DIRECTION_FORWARD]: + service = fan.SERVICE_SET_DIRECTION + data[fan.ATTR_DIRECTION] = direction + + await hass.services.async_call( + domain, service, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.ModeController", "AdjustMode")) +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. + """ + 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() + + +@HANDLERS.register(("Alexa.ToggleController", "TurnOn")) +async def async_api_toggle_on(hass, config, directive, context): + """Process a toggle on request.""" + entity = directive.entity + instance = directive.instance + domain = entity.domain + service = None + data = {ATTR_ENTITY_ID: entity.entity_id} + + if domain != fan.DOMAIN: + msg = "Entity does not support directive" + raise AlexaInvalidDirectiveError(msg) + + if instance == f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}": + service = fan.SERVICE_OSCILLATE + data[fan.ATTR_OSCILLATING] = True + + await hass.services.async_call( + domain, service, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.ToggleController", "TurnOff")) +async def async_api_toggle_off(hass, config, directive, context): + """Process a toggle off request.""" + entity = directive.entity + instance = directive.instance + domain = entity.domain + service = None + data = {ATTR_ENTITY_ID: entity.entity_id} + + if domain != fan.DOMAIN: + msg = "Entity does not support directive" + raise AlexaInvalidDirectiveError(msg) + + if instance == f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}": + service = fan.SERVICE_OSCILLATE + data[fan.ATTR_OSCILLATING] = False + + await hass.services.async_call( + domain, service, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.RangeController", "SetRangeValue")) +async def async_api_set_range(hass, config, directive, context): + """Process a next request.""" + entity = directive.entity + instance = directive.instance + domain = entity.domain + service = None + 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) + + if instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}": + service = fan.SERVICE_SET_SPEED + speed = SPEED_FAN_MAP.get(range_value, None) + + if not speed: + msg = "Entity does not support value" + raise AlexaInvalidValueError(msg) + + if speed == fan.SPEED_OFF: + service = fan.SERVICE_TURN_OFF + + data[fan.ATTR_SPEED] = speed + + await hass.services.async_call( + domain, service, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.RangeController", "AdjustRangeValue")) +async def async_api_adjust_range(hass, config, directive, context): + """Process a next request.""" + entity = directive.entity + instance = directive.instance + domain = entity.domain + service = None + data = {ATTR_ENTITY_ID: entity.entity_id} + range_delta = int(directive.payload["rangeValueDelta"]) + + 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) + + if speed == fan.SPEED_OFF: + service = fan.SERVICE_TURN_OFF + + data[fan.ATTR_SPEED] = speed + + await hass.services.async_call( + domain, service, data, blocking=False, context=context + ) + + return directive.response() diff --git a/homeassistant/components/alexa/messages.py b/homeassistant/components/alexa/messages.py index 3195656ed09..cb78f269f8f 100644 --- a/homeassistant/components/alexa/messages.py +++ b/homeassistant/components/alexa/messages.py @@ -28,7 +28,7 @@ class AlexaDirective: self.payload = self._directive[API_PAYLOAD] self.has_endpoint = API_ENDPOINT in self._directive - self.entity = self.entity_id = self.endpoint = None + self.entity = self.entity_id = self.endpoint = self.instance = None def load_entity(self, hass, config): """Set attributes related to the entity for this request. @@ -38,6 +38,7 @@ class AlexaDirective: - entity - entity_id - endpoint + - instance (when header includes instance property) Behavior when self.has_endpoint is False is undefined. @@ -52,6 +53,8 @@ class AlexaDirective: raise AlexaInvalidEndpointError(_endpoint_id) self.endpoint = ENTITY_ADAPTERS[self.entity.domain](hass, config, self.entity) + if "instance" in self._directive[API_HEADER]: + self.instance = self._directive[API_HEADER]["instance"] def response(self, name="Response", namespace="Alexa", payload=None): """Create an API formatted response. diff --git a/tests/components/alexa/__init__.py b/tests/components/alexa/__init__.py index 48406a11aef..4fd8bf6f2a9 100644 --- a/tests/components/alexa/__init__.py +++ b/tests/components/alexa/__init__.py @@ -67,13 +67,22 @@ def get_new_request(namespace, name, endpoint=None): async def assert_request_calls_service( - namespace, name, endpoint, service, hass, response_type="Response", payload=None + namespace, + name, + endpoint, + service, + hass, + response_type="Response", + payload=None, + instance=None, ): """Assert an API request calls a hass service.""" context = Context() request = get_new_request(namespace, name, endpoint) if payload: request["directive"]["payload"] = payload + if instance: + request["directive"]["header"]["instance"] = instance domain, service_name = service.split(".") calls = async_mock_service(hass, domain, service_name) diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index 280a76dc3f0..be4a2ba4806 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -305,7 +305,7 @@ async def test_report_colored_temp_light_state(hass): async def test_report_fan_speed_state(hass): - """Test PercentageController reports fan speed correctly.""" + """Test PercentageController, PowerLevelController, RangeController reports fan speed correctly.""" hass.states.async_set( "fan.off", "off", @@ -333,15 +333,82 @@ async def test_report_fan_speed_state(hass): properties = await reported_properties(hass, "fan.off") properties.assert_equal("Alexa.PercentageController", "percentage", 0) + properties.assert_equal("Alexa.PowerLevelController", "powerLevel", 0) + properties.assert_equal("Alexa.RangeController", "rangeValue", 0) properties = await reported_properties(hass, "fan.low_speed") properties.assert_equal("Alexa.PercentageController", "percentage", 33) + properties.assert_equal("Alexa.PowerLevelController", "powerLevel", 33) + properties.assert_equal("Alexa.RangeController", "rangeValue", 1) properties = await reported_properties(hass, "fan.medium_speed") properties.assert_equal("Alexa.PercentageController", "percentage", 66) + properties.assert_equal("Alexa.PowerLevelController", "powerLevel", 66) + properties.assert_equal("Alexa.RangeController", "rangeValue", 2) properties = await reported_properties(hass, "fan.high_speed") properties.assert_equal("Alexa.PercentageController", "percentage", 100) + properties.assert_equal("Alexa.PowerLevelController", "powerLevel", 100) + properties.assert_equal("Alexa.RangeController", "rangeValue", 3) + + +async def test_report_fan_oscillating(hass): + """Test ToggleController reports fan oscillating correctly.""" + hass.states.async_set( + "fan.off", + "off", + {"friendly_name": "Off fan", "speed": "off", "supported_features": 3}, + ) + hass.states.async_set( + "fan.low_speed", + "on", + { + "friendly_name": "Low speed fan", + "speed": "low", + "oscillating": True, + "supported_features": 3, + }, + ) + + properties = await reported_properties(hass, "fan.off") + properties.assert_equal("Alexa.ToggleController", "toggleState", "OFF") + + properties = await reported_properties(hass, "fan.low_speed") + properties.assert_equal("Alexa.ToggleController", "toggleState", "ON") + + +async def test_report_fan_direction(hass): + """Test ModeController reports fan direction correctly.""" + hass.states.async_set( + "fan.off", "off", {"friendly_name": "Off fan", "supported_features": 4} + ) + hass.states.async_set( + "fan.reverse", + "on", + { + "friendly_name": "Fan Reverse", + "direction": "reverse", + "supported_features": 4, + }, + ) + hass.states.async_set( + "fan.forward", + "on", + { + "friendly_name": "Fan Forward", + "direction": "forward", + "supported_features": 4, + }, + ) + + properties = await reported_properties(hass, "fan.off") + properties.assert_not_has_property("Alexa.ModeController", "mode") + + properties = await reported_properties(hass, "fan.reverse") + properties.assert_equal("Alexa.ModeController", "mode", "reverse") + + properties = await reported_properties(hass, "fan.forward") + properties.assert_equal("Alexa.ModeController", "mode", "forward") async def test_report_cover_percentage_state(hass): diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 186cb850e34..5a39036a30f 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -310,10 +310,14 @@ async def test_fan(hass): assert appliance["endpointId"] == "fan#test_1" assert appliance["displayCategories"][0] == "FAN" assert appliance["friendlyName"] == "Test fan 1" - assert_endpoint_capabilities( + capabilities = assert_endpoint_capabilities( appliance, "Alexa.PowerController", "Alexa.EndpointHealth" ) + power_capability = get_capability(capabilities, "Alexa.PowerController") + assert "capabilityResources" not in power_capability + assert "configuration" not in power_capability + async def test_variable_fan(hass): """Test fan discovery. @@ -336,14 +340,33 @@ async def test_variable_fan(hass): assert appliance["displayCategories"][0] == "FAN" assert appliance["friendlyName"] == "Test fan 2" - assert_endpoint_capabilities( + capabilities = assert_endpoint_capabilities( appliance, "Alexa.PercentageController", "Alexa.PowerController", "Alexa.PowerLevelController", + "Alexa.RangeController", "Alexa.EndpointHealth", ) + range_capability = get_capability(capabilities, "Alexa.RangeController") + assert range_capability is not None + assert range_capability["instance"] == "fan.speed" + + 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": "asset", + "value": {"assetId": "Alexa.Setting.FanSpeed"}, + } in capability_resources["friendlyNames"] + + configuration = range_capability["configuration"] + assert configuration is not None + call, _ = await assert_request_calls_service( "Alexa.PercentageController", "SetPercentage", @@ -377,7 +400,7 @@ async def test_variable_fan(hass): await assert_percentage_changes( hass, - [("high", "-5"), ("high", "5"), ("low", "-80")], + [("high", "-5"), ("medium", "-50"), ("low", "-80")], "Alexa.PowerLevelController", "AdjustPowerLevel", "fan#test_2", @@ -387,6 +410,251 @@ async def test_variable_fan(hass): ) +async def test_oscillating_fan(hass): + """Test oscillating fan discovery.""" + device = ( + "fan.test_3", + "off", + {"friendly_name": "Test fan 3", "supported_features": 3}, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "fan#test_3" + assert appliance["displayCategories"][0] == "FAN" + assert appliance["friendlyName"] == "Test fan 3" + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PercentageController", + "Alexa.PowerController", + "Alexa.PowerLevelController", + "Alexa.RangeController", + "Alexa.ToggleController", + "Alexa.EndpointHealth", + ) + + toggle_capability = get_capability(capabilities, "Alexa.ToggleController") + assert toggle_capability is not None + assert toggle_capability["instance"] == "fan.oscillating" + + properties = toggle_capability["properties"] + assert properties["nonControllable"] is False + assert {"name": "toggleState"} in properties["supported"] + + capability_resources = toggle_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "asset", + "value": {"assetId": "Alexa.Setting.Oscillate"}, + } in capability_resources["friendlyNames"] + + call, _ = await assert_request_calls_service( + "Alexa.ToggleController", + "TurnOn", + "fan#test_3", + "fan.oscillate", + hass, + payload={}, + instance="fan.oscillating", + ) + assert call.data["oscillating"] + + call, _ = await assert_request_calls_service( + "Alexa.ToggleController", + "TurnOff", + "fan#test_3", + "fan.oscillate", + hass, + payload={}, + instance="fan.oscillating", + ) + assert not call.data["oscillating"] + + +async def test_direction_fan(hass): + """Test direction fan discovery.""" + device = ( + "fan.test_4", + "on", + { + "friendly_name": "Test fan 4", + "supported_features": 5, + "direction": "forward", + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "fan#test_4" + assert appliance["displayCategories"][0] == "FAN" + assert appliance["friendlyName"] == "Test fan 4" + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PercentageController", + "Alexa.PowerController", + "Alexa.PowerLevelController", + "Alexa.RangeController", + "Alexa.ModeController", + "Alexa.EndpointHealth", + ) + + mode_capability = get_capability(capabilities, "Alexa.ModeController") + assert mode_capability is not None + assert mode_capability["instance"] == "fan.direction" + + properties = mode_capability["properties"] + assert properties["nonControllable"] is False + assert {"name": "mode"} in properties["supported"] + + capability_resources = mode_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "asset", + "value": {"assetId": "Alexa.Setting.Direction"}, + } in capability_resources["friendlyNames"] + + configuration = mode_capability["configuration"] + assert configuration is not None + assert configuration["ordered"] is False + + supported_modes = configuration["supportedModes"] + assert supported_modes is not None + assert { + "value": "direction.forward", + "modeResources": { + "friendlyNames": [ + {"@type": "text", "value": {"text": "forward", "locale": "en-US"}} + ] + }, + } in supported_modes + assert { + "value": "direction.reverse", + "modeResources": { + "friendlyNames": [ + {"@type": "text", "value": {"text": "reverse", "locale": "en-US"}} + ] + }, + } in supported_modes + + call, _ = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "fan#test_4", + "fan.set_direction", + hass, + payload={"mode": "direction.reverse"}, + instance="fan.direction", + ) + assert call.data["direction"] == "reverse" + + # Test for AdjustMode instance=None Error coverage + with pytest.raises(AssertionError): + call, _ = await assert_request_calls_service( + "Alexa.ModeController", + "AdjustMode", + "fan#test_4", + "fan.set_direction", + hass, + payload={}, + instance=None, + ) + assert call.data + + +async def test_fan_range(hass): + """Test fan discovery with range controller. + + This one has variable speed. + """ + device = ( + "fan.test_5", + "off", + { + "friendly_name": "Test fan 5", + "supported_features": 1, + "speed_list": ["low", "medium", "high"], + "speed": "medium", + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "fan#test_5" + assert appliance["displayCategories"][0] == "FAN" + assert appliance["friendlyName"] == "Test fan 5" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PercentageController", + "Alexa.PowerController", + "Alexa.PowerLevelController", + "Alexa.RangeController", + "Alexa.EndpointHealth", + ) + + range_capability = get_capability(capabilities, "Alexa.RangeController") + assert range_capability is not None + assert range_capability["instance"] == "fan.speed" + + call, _ = await assert_request_calls_service( + "Alexa.RangeController", + "SetRangeValue", + "fan#test_5", + "fan.set_speed", + hass, + payload={"rangeValue": "1"}, + instance="fan.speed", + ) + assert call.data["speed"] == "low" + + await assert_range_changes( + hass, + [("low", "-1"), ("high", "1"), ("medium", "0")], + "Alexa.RangeController", + "AdjustRangeValue", + "fan#test_5", + False, + "fan.set_speed", + "speed", + instance="fan.speed", + ) + + +async def test_fan_range_off(hass): + """Test fan range controller 0 turns_off fan.""" + device = ( + "fan.test_6", + "off", + { + "friendly_name": "Test fan 6", + "supported_features": 1, + "speed_list": ["low", "medium", "high"], + "speed": "high", + }, + ) + await discovery_test(device, hass) + + call, _ = await assert_request_calls_service( + "Alexa.RangeController", + "SetRangeValue", + "fan#test_6", + "fan.turn_off", + hass, + payload={"rangeValue": "0"}, + instance="fan.speed", + ) + assert call.data["speed"] == "off" + + await assert_range_changes( + hass, + [("off", "-3")], + "Alexa.RangeController", + "AdjustRangeValue", + "fan#test_6", + False, + "fan.turn_off", + "speed", + instance="fan.speed", + ) + + async def test_lock(hass): """Test lock discovery.""" device = ("lock.test", "off", {"friendly_name": "Test lock"}) @@ -729,6 +997,33 @@ async def assert_percentage_changes( assert call.data[changed_parameter] == result_volume +async def assert_range_changes( + hass, + adjustments, + namespace, + name, + endpoint, + delta_default, + service, + changed_parameter, + instance, +): + """Assert an API request making range changes works. + + AdjustRangeValue are examples of such requests. + """ + for result_range, adjustment in adjustments: + payload = { + "rangeValueDelta": adjustment, + "rangeValueDeltaDefault": delta_default, + } + + call, _ = await assert_request_calls_service( + namespace, name, endpoint, service, hass, payload=payload, instance=instance + ) + assert call.data[changed_parameter] == result_range + + async def test_temp_sensor(hass): """Test temperature sensor discovery.""" device = ( @@ -1438,3 +1733,41 @@ async def test_alarm_control_panel_code_arm_required(hass): {"friendly_name": "Test Alarm Control Panel 3", "code_arm_required": True}, ) await discovery_test(device, hass, expected_endpoints=0) + + +async def test_range_unsupported_domain(hass): + """Test rangeController with unsupported domain.""" + device = ("switch.test", "on", {"friendly_name": "Test switch"}) + await discovery_test(device, hass) + + context = Context() + request = get_new_request("Alexa.RangeController", "SetRangeValue", "switch#test") + request["directive"]["payload"] = {"rangeValue": "1"} + request["directive"]["header"]["instance"] = "switch.speed" + + msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request, context) + + assert "event" in msg + msg = msg["event"] + assert msg["header"]["name"] == "ErrorResponse" + assert msg["header"]["namespace"] == "Alexa" + assert msg["payload"]["type"] == "INVALID_DIRECTIVE" + + +async def test_mode_unsupported_domain(hass): + """Test modeController with unsupported domain.""" + device = ("switch.test", "on", {"friendly_name": "Test switch"}) + await discovery_test(device, hass) + + context = Context() + request = get_new_request("Alexa.ModeController", "SetMode", "switch#test") + request["directive"]["payload"] = {"mode": "testMode"} + request["directive"]["header"]["instance"] = "switch.direction" + + msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request, context) + + assert "event" in msg + msg = msg["event"] + assert msg["header"]["name"] == "ErrorResponse" + assert msg["header"]["namespace"] == "Alexa" + assert msg["payload"]["type"] == "INVALID_DIRECTIVE" diff --git a/tests/components/alexa/test_state_report.py b/tests/components/alexa/test_state_report.py index c05eed2a89b..310180ef5d0 100644 --- a/tests/components/alexa/test_state_report.py +++ b/tests/components/alexa/test_state_report.py @@ -37,6 +37,54 @@ async def test_report_state(hass, aioclient_mock): assert call_json["event"]["endpoint"]["endpointId"] == "binary_sensor#test_contact" +async def test_report_state_instance(hass, aioclient_mock): + """Test proactive state reports with instance.""" + aioclient_mock.post(TEST_URL, text="", status=202) + + hass.states.async_set( + "fan.test_fan", + "off", + { + "friendly_name": "Test fan", + "supported_features": 3, + "speed": "off", + "oscillating": False, + }, + ) + + await state_report.async_enable_proactive_mode(hass, DEFAULT_CONFIG) + + hass.states.async_set( + "fan.test_fan", + "on", + { + "friendly_name": "Test fan", + "supported_features": 3, + "speed": "high", + "oscillating": True, + }, + ) + + # To trigger event listener + await hass.async_block_till_done() + + assert len(aioclient_mock.mock_calls) == 1 + call = aioclient_mock.mock_calls + + call_json = call[0][2] + assert call_json["event"]["header"]["namespace"] == "Alexa" + assert call_json["event"]["header"]["name"] == "ChangeReport" + + change_reports = call_json["event"]["payload"]["change"]["properties"] + for report in change_reports: + if report["name"] == "toggleState": + assert report["value"] == "ON" + assert report["instance"] == "fan.oscillating" + assert report["namespace"] == "Alexa.ToggleController" + + assert call_json["event"]["endpoint"]["endpointId"] == "fan#test_fan" + + async def test_send_add_or_update_message(hass, aioclient_mock): """Test sending an AddOrUpdateReport message.""" aioclient_mock.post(TEST_URL, text="")