From 7403ba1e81579b4ab83da24e570d4afe864e6312 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 31 May 2021 20:58:01 +0200 Subject: [PATCH] Alexa fan preset_mode support (#50466) * fan preset_modes * process preset mode updates from alexa correctly * add tests * codecov patch additional tests --- .../components/alexa/capabilities.py | 19 ++++- homeassistant/components/alexa/entities.py | 5 ++ homeassistant/components/alexa/handlers.py | 10 +++ tests/components/alexa/test_capabilities.py | 42 +++++++++ tests/components/alexa/test_smart_home.py | 85 ++++++++++++++++++- tests/components/alexa/test_state_report.py | 34 +++++++- 6 files changed, 189 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 483b4484261..10b382c8dcf 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -1155,8 +1155,6 @@ class AlexaPowerLevelController(AlexaCapability): if self.entity.domain == fan.DOMAIN: return self.entity.attributes.get(fan.ATTR_PERCENTAGE) or 0 - return None - class AlexaSecurityPanelController(AlexaCapability): """Implements Alexa.SecurityPanelController. @@ -1304,6 +1302,12 @@ class AlexaModeController(AlexaCapability): if mode in (fan.DIRECTION_FORWARD, fan.DIRECTION_REVERSE, STATE_UNKNOWN): return f"{fan.ATTR_DIRECTION}.{mode}" + # Fan preset_mode + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}": + mode = self.entity.attributes.get(fan.ATTR_PRESET_MODE, None) + if mode in self.entity.attributes.get(fan.ATTR_PRESET_MODES, None): + return f"{fan.ATTR_PRESET_MODE}.{mode}" + # Cover Position if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": # Return state instead of position when using ModeController. @@ -1342,6 +1346,17 @@ class AlexaModeController(AlexaCapability): ) return self._resource.serialize_capability_resources() + # Fan preset_mode + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}": + self._resource = AlexaModeResource( + [AlexaGlobalCatalog.SETTING_PRESET], False + ) + for preset_mode in self.entity.attributes.get(fan.ATTR_PRESET_MODES, []): + self._resource.add_mode( + f"{fan.ATTR_PRESET_MODE}.{preset_mode}", [preset_mode] + ) + return self._resource.serialize_capability_resources() + # Cover Position Resources if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": self._resource = AlexaModeResource( diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 723d115b923..cef18623bf5 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -535,6 +535,7 @@ class FanCapabilities(AlexaEntity): if supported & fan.SUPPORT_SET_SPEED: yield AlexaPercentageController(self.entity) yield AlexaPowerLevelController(self.entity) + # The use of legacy speeds is deprecated in the schema, support will be removed after a quarter (2021.7) yield AlexaRangeController( self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_SPEED}" ) @@ -542,6 +543,10 @@ class FanCapabilities(AlexaEntity): yield AlexaToggleController( self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}" ) + if supported & fan.SUPPORT_PRESET_MODE: + yield AlexaModeController( + self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}" + ) if supported & fan.SUPPORT_DIRECTION: yield AlexaModeController( self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}" diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 79e322b4ea7..01d1369eb2f 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -958,6 +958,16 @@ async def async_api_set_mode(hass, config, directive, context): service = fan.SERVICE_SET_DIRECTION data[fan.ATTR_DIRECTION] = direction + # Fan preset_mode + elif instance == f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}": + preset_mode = mode.split(".")[1] + if preset_mode in entity.attributes.get(fan.ATTR_PRESET_MODES): + service = fan.SERVICE_SET_PRESET_MODE + data[fan.ATTR_PRESET_MODE] = preset_mode + else: + msg = f"Entity '{entity.entity_id}' does not support Preset '{preset_mode}'" + raise AlexaInvalidValueError(msg) + # Cover Position elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": position = mode.split(".")[1] diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index 020b03cc862..92951d4a0e7 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -400,6 +400,48 @@ async def test_report_fan_speed_state(hass): properties.assert_equal("Alexa.RangeController", "rangeValue", 3) +async def test_report_fan_preset_mode(hass): + """Test ModeController reports fan preset_mode correctly.""" + hass.states.async_set( + "fan.preset_mode", + "eco", + { + "friendly_name": "eco enabled fan", + "supported_features": 8, + "preset_mode": "eco", + "preset_modes": ["eco", "smart", "whoosh"], + }, + ) + properties = await reported_properties(hass, "fan.preset_mode") + properties.assert_equal("Alexa.ModeController", "mode", "preset_mode.eco") + + hass.states.async_set( + "fan.preset_mode", + "smart", + { + "friendly_name": "smart enabled fan", + "supported_features": 8, + "preset_mode": "smart", + "preset_modes": ["eco", "smart", "whoosh"], + }, + ) + properties = await reported_properties(hass, "fan.preset_mode") + properties.assert_equal("Alexa.ModeController", "mode", "preset_mode.smart") + + hass.states.async_set( + "fan.preset_mode", + "whoosh", + { + "friendly_name": "whoosh enabled fan", + "supported_features": 8, + "preset_mode": "whoosh", + "preset_modes": ["eco", "smart", "whoosh"], + }, + ) + properties = await reported_properties(hass, "fan.preset_mode") + properties.assert_equal("Alexa.ModeController", "mode", "preset_mode.whoosh") + + async def test_report_fan_oscillating(hass): """Test ToggleController reports fan oscillating correctly.""" hass.states.async_set( diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 83abe2326d7..0da21042049 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -846,6 +846,89 @@ async def test_fan_range_off(hass): ) +async def test_preset_mode_fan(hass, caplog): + """Test fan discovery. + + This one has preset modes. + """ + device = ( + "fan.test_7", + "off", + { + "friendly_name": "Test fan 7", + "supported_features": 8, + "preset_modes": ["auto", "eco", "smart", "whoosh"], + "preset_mode": "auto", + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "fan#test_7" + assert appliance["displayCategories"][0] == "FAN" + assert appliance["friendlyName"] == "Test fan 7" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.EndpointHealth", + "Alexa.ModeController", + "Alexa.PowerController", + "Alexa", + ) + + range_capability = get_capability(capabilities, "Alexa.ModeController") + assert range_capability is not None + assert range_capability["instance"] == "fan.preset_mode" + + properties = range_capability["properties"] + assert properties["nonControllable"] is False + assert {"name": "mode"} in properties["supported"] + + capability_resources = range_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "asset", + "value": {"assetId": "Alexa.Setting.Preset"}, + } in capability_resources["friendlyNames"] + + configuration = range_capability["configuration"] + assert configuration is not None + + call, _ = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "fan#test_7", + "fan.set_preset_mode", + hass, + payload={"mode": "preset_mode.eco"}, + instance="fan.preset_mode", + ) + assert call.data["preset_mode"] == "eco" + + call, _ = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "fan#test_7", + "fan.set_preset_mode", + hass, + payload={"mode": "preset_mode.whoosh"}, + instance="fan.preset_mode", + ) + assert call.data["preset_mode"] == "whoosh" + + with pytest.raises(AssertionError): + await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "fan#test_7", + "fan.set_preset_mode", + hass, + payload={"mode": "preset_mode.invalid"}, + instance="fan.preset_mode", + ) + assert "Entity 'fan.test_7' does not support Preset 'invalid'" in caplog.text + caplog.clear() + + async def test_lock(hass): """Test lock discovery.""" device = ("lock.test", "off", {"friendly_name": "Test lock"}) @@ -2484,7 +2567,7 @@ async def test_alarm_control_panel_disarmed(hass): properties = ReportedProperties(msg["context"]["properties"]) properties.assert_equal("Alexa.SecurityPanelController", "armState", "ARMED_AWAY") - call, msg = await assert_request_calls_service( + _, msg = await assert_request_calls_service( "Alexa.SecurityPanelController", "Arm", "alarm_control_panel#test_1", diff --git a/tests/components/alexa/test_state_report.py b/tests/components/alexa/test_state_report.py index 2cbf8636d79..bbe80f29eef 100644 --- a/tests/components/alexa/test_state_report.py +++ b/tests/components/alexa/test_state_report.py @@ -50,10 +50,13 @@ async def test_report_state_instance(hass, aioclient_mock): "off", { "friendly_name": "Test fan", - "supported_features": 3, - "speed": "off", + "supported_features": 15, + "speed": None, "speed_list": ["off", "low", "high"], "oscillating": False, + "preset_mode": None, + "preset_modes": ["auto", "smart"], + "percentage": None, }, ) @@ -64,10 +67,13 @@ async def test_report_state_instance(hass, aioclient_mock): "on", { "friendly_name": "Test fan", - "supported_features": 3, + "supported_features": 15, "speed": "high", "speed_list": ["off", "low", "high"], "oscillating": True, + "preset_mode": "smart", + "preset_modes": ["auto", "smart"], + "percentage": 90, }, ) @@ -82,11 +88,33 @@ async def test_report_state_instance(hass, aioclient_mock): assert call_json["event"]["header"]["name"] == "ChangeReport" change_reports = call_json["event"]["payload"]["change"]["properties"] + + checks = 0 for report in change_reports: if report["name"] == "toggleState": assert report["value"] == "ON" assert report["instance"] == "fan.oscillating" assert report["namespace"] == "Alexa.ToggleController" + checks += 1 + if report["name"] == "mode": + assert report["value"] == "preset_mode.smart" + assert report["instance"] == "fan.preset_mode" + assert report["namespace"] == "Alexa.ModeController" + checks += 1 + if report["name"] == "percentage": + assert report["value"] == 90 + assert report["namespace"] == "Alexa.PercentageController" + checks += 1 + if report["name"] == "powerLevel": + assert report["value"] == 90 + assert report["namespace"] == "Alexa.PowerLevelController" + checks += 1 + if report["name"] == "rangeValue": + assert report["value"] == 2 + assert report["instance"] == "fan.speed" + assert report["namespace"] == "Alexa.RangeController" + checks += 1 + assert checks == 5 assert call_json["event"]["endpoint"]["endpointId"] == "fan#test_fan"