diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 15870c7bbfa..f54f66b814c 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -8,6 +8,7 @@ from homeassistant.components import ( climate, cover, fan, + humidifier, image_processing, input_button, input_number, @@ -398,6 +399,8 @@ class AlexaPowerController(AlexaCapability): is_on = self.entity.state != climate.HVACMode.OFF elif self.entity.domain == fan.DOMAIN: is_on = self.entity.state == fan.STATE_ON + elif self.entity.domain == humidifier.DOMAIN: + is_on = self.entity.state == humidifier.STATE_ON elif self.entity.domain == vacuum.DOMAIN: is_on = self.entity.state == vacuum.STATE_CLEANING elif self.entity.domain == timer.DOMAIN: @@ -1403,6 +1406,12 @@ class AlexaModeController(AlexaCapability): if mode in self.entity.attributes.get(fan.ATTR_PRESET_MODES, None): return f"{fan.ATTR_PRESET_MODE}.{mode}" + # Humidifier mode + if self.instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}": + mode = self.entity.attributes.get(humidifier.ATTR_MODE, None) + if mode in self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES, []): + return f"{humidifier.ATTR_MODE}.{mode}" + # Cover Position if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": # Return state instead of position when using ModeController. @@ -1459,6 +1468,20 @@ class AlexaModeController(AlexaCapability): ) return self._resource.serialize_capability_resources() + # Humidifier modes + if self.instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}": + self._resource = AlexaModeResource([AlexaGlobalCatalog.SETTING_MODE], False) + modes = self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES, []) + for mode in modes: + self._resource.add_mode(f"{humidifier.ATTR_MODE}.{mode}", [mode]) + # Humidifiers or Fans with a single mode completely break Alexa discovery, add a + # fake preset (see issue #53832). + if len(modes) == 1: + self._resource.add_mode( + f"{humidifier.ATTR_MODE}.{PRESET_MODE_NA}", [PRESET_MODE_NA] + ) + return self._resource.serialize_capability_resources() + # Cover Position Resources if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": self._resource = AlexaModeResource( @@ -1600,6 +1623,12 @@ class AlexaRangeController(AlexaCapability): return self.entity.attributes.get(fan.ATTR_PERCENTAGE) return 100 if self.entity.state == fan.STATE_ON else 0 + # Humidifier target humidity + if self.instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}": + # If the humidifier is turned off the target humidity attribute is not set. + # We return 0 to make clear we do not know the current value. + return self.entity.attributes.get(humidifier.ATTR_HUMIDITY, 0) + # Input Number Value if self.instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}": return float(self.entity.state) @@ -1640,6 +1669,17 @@ class AlexaRangeController(AlexaCapability): ) return self._resource.serialize_capability_resources() + # Humidifier Target Humidity Resources + if self.instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}": + self._resource = AlexaPresetResource( + labels=["Humidity", "Percentage", "Target humidity"], + min_value=self.entity.attributes.get(humidifier.ATTR_MIN_HUMIDITY, 10), + max_value=self.entity.attributes.get(humidifier.ATTR_MAX_HUMIDITY, 90), + precision=1, + unit=AlexaGlobalCatalog.UNIT_PERCENT, + ) + return self._resource.serialize_capability_resources() + # Cover Position Resources if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": self._resource = AlexaPresetResource( @@ -1764,6 +1804,22 @@ class AlexaRangeController(AlexaCapability): ) return self._semantics.serialize_semantics() + # Target Humidity Percentage + if self.instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}": + lower_labels = [AlexaSemantics.ACTION_LOWER] + raise_labels = [AlexaSemantics.ACTION_RAISE] + self._semantics = AlexaSemantics() + min_value = self.entity.attributes.get(humidifier.ATTR_MIN_HUMIDITY, 10) + max_value = self.entity.attributes.get(humidifier.ATTR_MAX_HUMIDITY, 90) + + self._semantics.add_action_to_directive( + lower_labels, "SetRangeValue", {"rangeValue": min_value} + ) + self._semantics.add_action_to_directive( + raise_labels, "SetRangeValue", {"rangeValue": max_value} + ) + return self._semantics.serialize_semantics() + return None diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py index d1061720718..a34355b7ddb 100644 --- a/homeassistant/components/alexa/const.py +++ b/homeassistant/components/alexa/const.py @@ -83,7 +83,8 @@ API_THERMOSTAT_MODES_CUSTOM = { } API_THERMOSTAT_PRESETS = {climate.PRESET_ECO: "ECO"} -# AlexaModeController does not like a single mode for the fan preset, we add PRESET_MODE_NA if a fan has only one preset_mode +# AlexaModeController does not like a single mode for the fan preset or humidifier mode, +# we add PRESET_MODE_NA if a fan / humidifier has only one preset_mode PRESET_MODE_NA = "-" diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index e002969952a..35313573b19 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -15,6 +15,7 @@ from homeassistant.components import ( cover, fan, group, + humidifier, image_processing, input_boolean, input_button, @@ -100,6 +101,9 @@ class DisplayCategory: # to HDMI1. Applies to Scenes ACTIVITY_TRIGGER = "ACTIVITY_TRIGGER" + # Indicates a device that cools the air in interior spaces. + AIR_CONDITIONER = "AIR_CONDITIONER" + # Indicates a device that emits pleasant odors and masks unpleasant odors in interior spaces. AIR_FRESHENER = "AIR_FRESHENER" @@ -583,6 +587,30 @@ class FanCapabilities(AlexaEntity): yield Alexa(self.hass) +@ENTITY_ADAPTERS.register(humidifier.DOMAIN) +class HumidifierCapabilities(AlexaEntity): + """Class to represent Humidifier capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + 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 & humidifier.HumidifierEntityFeature.MODES: + yield AlexaModeController( + self.entity, instance=f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}" + ) + yield AlexaRangeController( + self.entity, instance=f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}" + ) + + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.hass) + + @ENTITY_ADAPTERS.register(lock.DOMAIN) class LockCapabilities(AlexaEntity): """Class to represent Lock capabilities.""" diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index b4c842dd5b5..3f816501cf3 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -14,6 +14,7 @@ from homeassistant.components import ( cover, fan, group, + humidifier, input_button, input_number, light, @@ -154,6 +155,8 @@ async def async_api_turn_on( service = cover.SERVICE_OPEN_COVER elif domain == fan.DOMAIN: service = fan.SERVICE_TURN_ON + elif domain == humidifier.DOMAIN: + service = humidifier.SERVICE_TURN_ON elif domain == vacuum.DOMAIN: supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if ( @@ -201,6 +204,8 @@ async def async_api_turn_off( service = cover.SERVICE_CLOSE_COVER elif domain == fan.DOMAIN: service = fan.SERVICE_TURN_OFF + elif domain == humidifier.DOMAIN: + service = humidifier.SERVICE_TURN_OFF elif domain == vacuum.DOMAIN: supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if ( @@ -448,20 +453,31 @@ async def async_api_set_percentage( """Process a set percentage request.""" entity = directive.entity - if entity.domain != fan.DOMAIN: + if entity.domain == fan.DOMAIN: + percentage = int(directive.payload["percentage"]) + service = fan.SERVICE_SET_PERCENTAGE + data = { + ATTR_ENTITY_ID: entity.entity_id, + fan.ATTR_PERCENTAGE: percentage, + } + + await hass.services.async_call( + entity.domain, service, data, blocking=False, context=context + ) + elif entity.domain == humidifier.DOMAIN: + percentage = int(directive.payload["percentage"]) + service = humidifier.SERVICE_SET_HUMIDITY + data = { + ATTR_ENTITY_ID: entity.entity_id, + humidifier.ATTR_HUMIDITY: percentage, + } + + await hass.services.async_call( + entity.domain, service, data, blocking=False, context=context + ) + else: raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) - percentage = int(directive.payload["percentage"]) - service = fan.SERVICE_SET_PERCENTAGE - data = { - ATTR_ENTITY_ID: entity.entity_id, - fan.ATTR_PERCENTAGE: percentage, - } - - await hass.services.async_call( - entity.domain, service, data, blocking=False, context=context - ) - return directive.response() @@ -1130,6 +1146,18 @@ async def async_api_set_mode( msg = f"Entity '{entity.entity_id}' does not support Preset '{preset_mode}'" raise AlexaInvalidValueError(msg) + # Humidifier mode + elif instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}": + mode = mode.split(".")[1] + if mode != PRESET_MODE_NA and mode in entity.attributes.get( + humidifier.ATTR_AVAILABLE_MODES + ): + service = humidifier.SERVICE_SET_MODE + data[humidifier.ATTR_MODE] = mode + else: + msg = f"Entity '{entity.entity_id}' does not support Mode '{mode}'" + raise AlexaInvalidValueError(msg) + # Cover Position elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": position = mode.split(".")[1] @@ -1306,6 +1334,12 @@ async def async_api_set_range( else: service = fan.SERVICE_TURN_ON + # Humidifier target humidity + elif instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}": + range_value = int(range_value) + service = humidifier.SERVICE_SET_HUMIDITY + data[humidifier.ATTR_HUMIDITY] = range_value + # Input Number Value elif instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}": range_value = float(range_value) @@ -1414,6 +1448,26 @@ async def async_api_adjust_range( else: service = fan.SERVICE_TURN_OFF + # Humidifier target humidity + elif instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}": + percentage_step = 5 + range_delta = ( + int(range_delta * percentage_step) + if range_delta_default + else int(range_delta) + ) + service = humidifier.SERVICE_SET_HUMIDITY + if not (current := entity.attributes.get(humidifier.ATTR_HUMIDITY)): + msg = f"Unable to determine {entity.entity_id} current target humidity" + raise AlexaInvalidValueError(msg) + min_value = entity.attributes.get(humidifier.ATTR_MIN_HUMIDITY, 10) + max_value = entity.attributes.get(humidifier.ATTR_MAX_HUMIDITY, 90) + percentage = response_value = min( + max_value, max(min_value, range_delta + current) + ) + if percentage: + data[humidifier.ATTR_HUMIDITY] = percentage + # Input Number Value elif instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}": range_delta = float(range_delta) diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index 608ff428d04..13d095f1cc6 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -411,6 +411,72 @@ async def test_report_fan_speed_state(hass): properties.assert_equal("Alexa.RangeController", "rangeValue", 0) +async def test_report_humidifier_humidity_state(hass): + """Test PercentageController, PowerLevelController reports humidifier humidity correctly.""" + hass.states.async_set( + "humidifier.dry", + "on", + { + "friendly_name": "Humidifier dry", + "supported_features": 0, + "humidity": 25, + "min_humidity": 20, + "max_humidity": 90, + }, + ) + hass.states.async_set( + "humidifier.wet", + "on", + { + "friendly_name": "Humidifier wet", + "supported_features": 0, + "humidity": 80, + "min_humidity": 20, + "max_humidity": 90, + }, + ) + properties = await reported_properties(hass, "humidifier.dry") + properties.assert_equal("Alexa.RangeController", "rangeValue", 25) + + properties = await reported_properties(hass, "humidifier.wet") + properties.assert_equal("Alexa.RangeController", "rangeValue", 80) + + +async def test_report_humidifier_mode(hass): + """Test ModeController reports humidifier mode correctly.""" + hass.states.async_set( + "humidifier.auto", + "on", + { + "friendly_name": "Humidifier auto", + "supported_features": 1, + "humidity": 50, + "mode": "Auto", + "available_modes": ["Auto", "Low", "Medium", "High"], + "min_humidity": 20, + "max_humidity": 90, + }, + ) + properties = await reported_properties(hass, "humidifier.auto") + properties.assert_equal("Alexa.ModeController", "mode", "mode.Auto") + + hass.states.async_set( + "humidifier.medium", + "on", + { + "friendly_name": "Humidifier auto", + "supported_features": 1, + "humidity": 60, + "mode": "Medium", + "available_modes": ["Auto", "Low", "Medium", "High"], + "min_humidity": 20, + "max_humidity": 90, + }, + ) + properties = await reported_properties(hass, "humidifier.medium") + properties.assert_equal("Alexa.ModeController", "mode", "mode.Medium") + + async def test_report_fan_preset_mode(hass): """Test ModeController reports fan preset_mode correctly.""" hass.states.async_set( diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index e2ae8741f20..3b76654f312 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -950,6 +950,145 @@ async def test_single_preset_mode_fan(hass, caplog): caplog.clear() +@freeze_time("2022-04-19 07:53:05") +async def test_humidifier(hass, caplog): + """Test humidifier controller.""" + device = ( + "humidifier.test_1", + "on", + { + "friendly_name": "Humidifier test 1", + "humidity": 66, + "supported_features": 1, + "mode": "Auto", + "available_modes": ["Auto", "Low", "Medium", "High"], + "min_humidity": 20, + "max_humidity": 90, + }, + ) + await discovery_test(device, hass) + + await assert_power_controller_works( + "humidifier#test_1", + "humidifier.turn_on", + "humidifier.turn_off", + hass, + "2022-04-19T07:53:05Z", + ) + + call, _ = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "humidifier#test_1", + "humidifier.set_mode", + hass, + payload={"mode": "mode.Auto"}, + instance="humidifier.mode", + ) + assert call.data["mode"] == "Auto" + + with pytest.raises(AssertionError): + await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "humidifier#test_1", + "humidifier.set_mode", + hass, + payload={"mode": "mode.-"}, + instance="humidifier.mode", + ) + assert "Entity 'humidifier.test_1' does not support Mode '-'" in caplog.text + caplog.clear() + + call, _ = await assert_request_calls_service( + "Alexa.RangeController", + "SetRangeValue", + "humidifier#test_1", + "humidifier.set_humidity", + hass, + payload={"rangeValue": "67"}, + instance="humidifier.humidity", + ) + assert call.data["humidity"] == 67 + call, _ = await assert_request_calls_service( + "Alexa.RangeController", + "SetRangeValue", + "humidifier#test_1", + "humidifier.set_humidity", + hass, + payload={"rangeValue": "33"}, + instance="humidifier.humidity", + ) + assert call.data["humidity"] == 33 + + +async def test_humidifier_without_modes(hass): + """Test humidifier discovery without modes.""" + + device = ( + "humidifier.test_2", + "on", + { + "friendly_name": "Humidifier test 2", + "humidity": 33, + "supported_features": 0, + "min_humidity": 20, + "max_humidity": 90, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "humidifier#test_2" + assert appliance["displayCategories"][0] == "OTHER" + assert appliance["friendlyName"] == "Humidifier test 2" + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.RangeController", + "Alexa.PowerController", + "Alexa.EndpointHealth", + "Alexa", + ) + + power_capability = get_capability(capabilities, "Alexa.PowerController") + assert "capabilityResources" not in power_capability + assert "configuration" not in power_capability + + +async def test_humidifier_with_modes(hass): + """Test humidifier discovery with modes.""" + + device = ( + "humidifier.test_1", + "on", + { + "friendly_name": "Humidifier test 1", + "humidity": 66, + "supported_features": 1, + "mode": "Auto", + "available_modes": ["Auto", "Low", "Medium", "High"], + "min_humidity": 20, + "max_humidity": 90, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "humidifier#test_1" + assert appliance["displayCategories"][0] == "OTHER" + assert appliance["friendlyName"] == "Humidifier test 1" + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.ModeController", + "Alexa.RangeController", + "Alexa.PowerController", + "Alexa.EndpointHealth", + "Alexa", + ) + + power_capability = get_capability(capabilities, "Alexa.PowerController") + assert "capabilityResources" not in power_capability + assert "configuration" not in power_capability + + async def test_lock(hass): """Test lock discovery.""" device = ("lock.test", "off", {"friendly_name": "Test lock"}) diff --git a/tests/components/alexa/test_state_report.py b/tests/components/alexa/test_state_report.py index bb61cea2413..ed70afc02d6 100644 --- a/tests/components/alexa/test_state_report.py +++ b/tests/components/alexa/test_state_report.py @@ -209,8 +209,8 @@ async def test_report_state_unsets_authorized_on_access_token_error( config._store.set_authorized.assert_called_once_with(False) -async def test_report_state_instance(hass, aioclient_mock): - """Test proactive state reports with instance.""" +async def test_report_state_fan(hass, aioclient_mock): + """Test proactive state reports with fan instance.""" aioclient_mock.post(TEST_URL, text="", status=202) hass.states.async_set( @@ -275,6 +275,64 @@ async def test_report_state_instance(hass, aioclient_mock): assert call_json["event"]["endpoint"]["endpointId"] == "fan#test_fan" +async def test_report_state_humidifier(hass, aioclient_mock): + """Test proactive state reports with humidifier instance.""" + aioclient_mock.post(TEST_URL, text="", status=202) + + hass.states.async_set( + "humidifier.test_humidifier", + "off", + { + "friendly_name": "Test humidifier", + "supported_features": 1, + "mode": None, + "available_modes": ["auto", "smart"], + }, + ) + + await state_report.async_enable_proactive_mode(hass, get_default_config(hass)) + + hass.states.async_set( + "humidifier.test_humidifier", + "on", + { + "friendly_name": "Test humidifier", + "supported_features": 1, + "mode": "smart", + "available_modes": ["auto", "smart"], + "humidity": 55, + }, + ) + + # 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"] + + checks = 0 + for report in change_reports: + if report["name"] == "mode": + assert report["value"] == "mode.smart" + assert report["instance"] == "humidifier.mode" + assert report["namespace"] == "Alexa.ModeController" + checks += 1 + if report["name"] == "rangeValue": + assert report["value"] == 55 + assert report["instance"] == "humidifier.humidity" + assert report["namespace"] == "Alexa.RangeController" + checks += 1 + assert checks == 2 + + assert call_json["event"]["endpoint"]["endpointId"] == "humidifier#test_humidifier" + + async def test_send_add_or_update_message(hass, aioclient_mock): """Test sending an AddOrUpdateReport message.""" aioclient_mock.post(TEST_URL, text="")