diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index c1314aeaa41..c1b8d704608 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -7,6 +7,7 @@ from homeassistant.components import ( cover, fan, group, + humidifier, input_boolean, input_select, light, @@ -44,6 +45,7 @@ DEFAULT_EXPOSED_DOMAINS = [ "cover", "fan", "group", + "humidifier", "input_boolean", "input_select", "light", @@ -76,6 +78,8 @@ TYPE_TV = f"{PREFIX_TYPES}TV" TYPE_SPEAKER = f"{PREFIX_TYPES}SPEAKER" TYPE_ALARM = f"{PREFIX_TYPES}SECURITYSYSTEM" TYPE_SETTOP = f"{PREFIX_TYPES}SETTOP" +TYPE_HUMIDIFIER = f"{PREFIX_TYPES}HUMIDIFIER" +TYPE_DEHUMIDIFIER = f"{PREFIX_TYPES}DEHUMIDIFIER" SERVICE_REQUEST_SYNC = "request_sync" HOMEGRAPH_URL = "https://homegraph.googleapis.com/" @@ -114,6 +118,7 @@ DOMAIN_TO_GOOGLE_TYPES = { cover.DOMAIN: TYPE_BLINDS, fan.DOMAIN: TYPE_FAN, group.DOMAIN: TYPE_SWITCH, + humidifier.DOMAIN: TYPE_HUMIDIFIER, input_boolean.DOMAIN: TYPE_SWITCH, input_select.DOMAIN: TYPE_SENSOR, light.DOMAIN: TYPE_LIGHT, @@ -140,6 +145,8 @@ DEVICE_CLASS_TO_GOOGLE_TYPES = { (media_player.DOMAIN, media_player.DEVICE_CLASS_TV): TYPE_TV, (sensor.DOMAIN, sensor.DEVICE_CLASS_TEMPERATURE): TYPE_SENSOR, (sensor.DOMAIN, sensor.DEVICE_CLASS_HUMIDITY): TYPE_SENSOR, + (humidifier.DOMAIN, humidifier.DEVICE_CLASS_HUMIDIFIER): TYPE_HUMIDIFIER, + (humidifier.DOMAIN, humidifier.DEVICE_CLASS_DEHUMIDIFIER): TYPE_DEHUMIDIFIER, } CHALLENGE_ACK_NEEDED = "ackNeeded" diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 3ed31f35e48..f6301c8579d 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -20,6 +20,7 @@ from homeassistant.components import ( vacuum, ) from homeassistant.components.climate import const as climate +from homeassistant.components.humidifier import const as humidifier from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_CODE, @@ -123,6 +124,7 @@ COMMAND_MEDIA_SEEK_RELATIVE = f"{PREFIX_COMMANDS}mediaSeekRelative" COMMAND_MEDIA_SEEK_TO_POSITION = f"{PREFIX_COMMANDS}mediaSeekToPosition" COMMAND_MEDIA_SHUFFLE = f"{PREFIX_COMMANDS}mediaShuffle" COMMAND_MEDIA_STOP = f"{PREFIX_COMMANDS}mediaStop" +COMMAND_SET_HUMIDITY = f"{PREFIX_COMMANDS}SetHumidity" TRAITS = [] @@ -287,6 +289,7 @@ class OnOffTrait(_Trait): fan.DOMAIN, light.DOMAIN, media_player.DOMAIN, + humidifier.DOMAIN, ) def sync_attributes(self): @@ -295,7 +298,7 @@ class OnOffTrait(_Trait): def query_attributes(self): """Return OnOff query attributes.""" - return {"on": self.state.state != STATE_OFF} + return {"on": self.state.state not in (STATE_OFF, STATE_UNKNOWN)} async def execute(self, command, data, params, challenge): """Execute an OnOff command.""" @@ -883,11 +886,14 @@ class HumiditySettingTrait(_Trait): """ name = TRAIT_HUMIDITY_SETTING - commands = [] + commands = [COMMAND_SET_HUMIDITY] @staticmethod def supported(domain, features, device_class): """Test if state is supported.""" + if domain == humidifier.DOMAIN: + return True + return domain == sensor.DOMAIN and device_class == sensor.DEVICE_CLASS_HUMIDITY def sync_attributes(self): @@ -895,11 +901,22 @@ class HumiditySettingTrait(_Trait): response = {} attrs = self.state.attributes domain = self.state.domain + if domain == sensor.DOMAIN: device_class = attrs.get(ATTR_DEVICE_CLASS) if device_class == sensor.DEVICE_CLASS_HUMIDITY: response["queryOnlyHumiditySetting"] = True + elif domain == humidifier.DOMAIN: + response["humiditySetpointRange"] = { + "minPercent": round( + float(self.state.attributes[humidifier.ATTR_MIN_HUMIDITY]) + ), + "maxPercent": round( + float(self.state.attributes[humidifier.ATTR_MAX_HUMIDITY]) + ), + } + return response def query_attributes(self): @@ -907,6 +924,7 @@ class HumiditySettingTrait(_Trait): response = {} attrs = self.state.attributes domain = self.state.domain + if domain == sensor.DOMAIN: device_class = attrs.get(ATTR_DEVICE_CLASS) if device_class == sensor.DEVICE_CLASS_HUMIDITY: @@ -914,16 +932,34 @@ class HumiditySettingTrait(_Trait): if current_humidity not in (STATE_UNKNOWN, STATE_UNAVAILABLE): response["humidityAmbientPercent"] = round(float(current_humidity)) + elif domain == humidifier.DOMAIN: + target_humidity = attrs.get(humidifier.ATTR_HUMIDITY) + if target_humidity is not None: + response["humiditySetpointPercent"] = round(float(target_humidity)) + return response async def execute(self, command, data, params, challenge): """Execute a humidity command.""" domain = self.state.domain + if domain == sensor.DOMAIN: raise SmartHomeError( ERR_NOT_SUPPORTED, "Execute is not supported by sensor" ) + if command == COMMAND_SET_HUMIDITY: + await self.hass.services.async_call( + humidifier.DOMAIN, + humidifier.SERVICE_SET_HUMIDITY, + { + ATTR_ENTITY_ID: self.state.entity_id, + humidifier.ATTR_HUMIDITY: params["humidity"], + }, + blocking=True, + context=data.context, + ) + @register_trait class LockUnlockTrait(_Trait): @@ -1151,7 +1187,6 @@ class FanSpeedTrait(_Trait): speed = attrs.get(fan.ATTR_SPEED) if speed is not None: response["on"] = speed != fan.SPEED_OFF - response["online"] = True response["currentFanSpeedSetting"] = speed return response @@ -1189,6 +1224,9 @@ class ModesTrait(_Trait): if domain == input_select.DOMAIN: return True + if domain == humidifier.DOMAIN and features & humidifier.SUPPORT_MODES: + return True + if domain != media_player.DOMAIN: return False @@ -1241,6 +1279,9 @@ class ModesTrait(_Trait): ) elif self.state.domain == input_select.DOMAIN: modes.append(_generate("option", attrs[input_select.ATTR_OPTIONS])) + elif self.state.domain == humidifier.DOMAIN: + if humidifier.ATTR_AVAILABLE_MODES in attrs: + modes.append(_generate("mode", attrs[humidifier.ATTR_AVAILABLE_MODES])) payload = {"availableModes": modes} @@ -1262,16 +1303,18 @@ class ModesTrait(_Trait): mode_settings["sound mode"] = attrs.get(media_player.ATTR_SOUND_MODE) elif self.state.domain == input_select.DOMAIN: mode_settings["option"] = self.state.state + elif self.state.domain == humidifier.DOMAIN: + if humidifier.ATTR_MODE in attrs: + mode_settings["mode"] = attrs.get(humidifier.ATTR_MODE) if mode_settings: - response["on"] = self.state.state != STATE_OFF - response["online"] = True + response["on"] = self.state.state not in (STATE_OFF, STATE_UNKNOWN) response["currentModeSettings"] = mode_settings return response async def execute(self, command, data, params, challenge): - """Execute an SetModes command.""" + """Execute a SetModes command.""" settings = params.get("updateModeSettings") if self.state.domain == input_select.DOMAIN: @@ -1286,8 +1329,22 @@ class ModesTrait(_Trait): blocking=True, context=data.context, ) - return + + if self.state.domain == humidifier.DOMAIN: + requested_mode = settings["mode"] + await self.hass.services.async_call( + humidifier.DOMAIN, + humidifier.SERVICE_SET_MODE, + { + humidifier.ATTR_MODE: requested_mode, + ATTR_ENTITY_ID: self.state.entity_id, + }, + blocking=True, + context=data.context, + ) + return + if self.state.domain != media_player.DOMAIN: _LOGGER.info( "Received an Options command for unrecognised domain %s", diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 2335270dc4a..6283b2d8665 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -122,6 +122,7 @@ ALLOWED_USED_COMPONENTS = { "cover", "device_tracker", "fan", + "humidifier", "image_processing", "light", "lock", diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index e8b5cd87be0..45adc281524 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -250,6 +250,40 @@ DEMO_DEVICES = [ "type": "action.devices.types.THERMOSTAT", "willReportState": False, }, + { + "id": "humidifier.humidifier", + "name": {"name": "Humidifier"}, + "traits": [ + "action.devices.traits.HumiditySetting", + "action.devices.traits.OnOff", + ], + "type": "action.devices.types.HUMIDIFIER", + "willReportState": False, + "attributes": {"humiditySetpointRange": {"minPercent": 0, "maxPercent": 100}}, + }, + { + "id": "humidifier.dehumidifier", + "name": {"name": "Dehumidifier"}, + "traits": [ + "action.devices.traits.HumiditySetting", + "action.devices.traits.OnOff", + ], + "type": "action.devices.types.DEHUMIDIFIER", + "willReportState": False, + "attributes": {"humiditySetpointRange": {"minPercent": 0, "maxPercent": 100}}, + }, + { + "id": "humidifier.hygrostat", + "name": {"name": "Hygrostat"}, + "traits": [ + "action.devices.traits.HumiditySetting", + "action.devices.traits.Modes", + "action.devices.traits.OnOff", + ], + "type": "action.devices.types.HUMIDIFIER", + "willReportState": False, + "attributes": {"humiditySetpointRange": {"minPercent": 0, "maxPercent": 100}}, + }, { "id": "lock.front_door", "name": {"name": "Front Door"}, diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index c806e656762..e4beaa14bba 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -17,6 +17,7 @@ from homeassistant.components import ( switch, ) from homeassistant.components.climate import const as climate +from homeassistant.components.humidifier import const as humidifier from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from . import DEMO_DEVICES @@ -96,6 +97,12 @@ def hass_fixture(loop, hass): ) ) + loop.run_until_complete( + setup.async_setup_component( + hass, humidifier.DOMAIN, {"humidifier": [{"platform": "demo"}]} + ) + ) + loop.run_until_complete( setup.async_setup_component(hass, lock.DOMAIN, {"lock": [{"platform": "demo"}]}) ) @@ -292,6 +299,52 @@ async def test_query_climate_request_f(hass_fixture, assistant_client, auth_head hass_fixture.config.units.temperature_unit = const.TEMP_CELSIUS +async def test_query_humidifier_request(hass_fixture, assistant_client, auth_header): + """Test a query request.""" + reqid = "5711642932632160984" + data = { + "requestId": reqid, + "inputs": [ + { + "intent": "action.devices.QUERY", + "payload": { + "devices": [ + {"id": "humidifier.humidifier"}, + {"id": "humidifier.dehumidifier"}, + {"id": "humidifier.hygrostat"}, + ] + }, + } + ], + } + result = await assistant_client.post( + ga.const.GOOGLE_ASSISTANT_API_ENDPOINT, + data=json.dumps(data), + headers=auth_header, + ) + assert result.status == 200 + body = await result.json() + assert body.get("requestId") == reqid + devices = body["payload"]["devices"] + assert len(devices) == 3 + assert devices["humidifier.humidifier"] == { + "on": True, + "online": True, + "humiditySetpointPercent": 68, + } + assert devices["humidifier.dehumidifier"] == { + "on": True, + "online": True, + "humiditySetpointPercent": 54, + } + assert devices["humidifier.hygrostat"] == { + "on": True, + "online": True, + "humiditySetpointPercent": 50, + "currentModeSettings": {"mode": "home"}, + } + + async def test_execute_request(hass_fixture, assistant_client, auth_header): """Test an execute request.""" reqid = "5711642932632160985" @@ -346,6 +399,33 @@ async def test_execute_request(hass_fixture, assistant_client, auth_header): }, ], }, + { + "devices": [{"id": "humidifier.humidifier"}], + "execution": [ + { + "command": "action.devices.commands.OnOff", + "params": {"on": False}, + } + ], + }, + { + "devices": [{"id": "humidifier.dehumidifier"}], + "execution": [ + { + "command": "action.devices.commands.SetHumidity", + "params": {"humidity": 45}, + } + ], + }, + { + "devices": [{"id": "humidifier.hygrostat"}], + "execution": [ + { + "command": "action.devices.commands.SetModes", + "params": {"updateModeSettings": {"mode": "eco"}}, + } + ], + }, ] }, } @@ -360,7 +440,7 @@ async def test_execute_request(hass_fixture, assistant_client, auth_header): body = await result.json() assert body.get("requestId") == reqid commands = body["payload"]["commands"] - assert len(commands) == 6 + assert len(commands) == 9 assert not any(result["status"] == "ERROR" for result in commands) @@ -381,3 +461,12 @@ async def test_execute_request(hass_fixture, assistant_client, auth_header): lounge = hass_fixture.states.get("media_player.lounge_room") assert lounge.state == "off" + + humidifier_state = hass_fixture.states.get("humidifier.humidifier") + assert humidifier_state.state == "off" + + dehumidifier = hass_fixture.states.get("humidifier.dehumidifier") + assert dehumidifier.attributes.get(humidifier.ATTR_HUMIDITY) == 45 + + hygrostat = hass_fixture.states.get("humidifier.hygrostat") + assert hygrostat.attributes.get(humidifier.ATTR_MODE) == "eco" diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index cf8fde8af7b..adcdbd8291d 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -24,6 +24,7 @@ from homeassistant.components import ( ) from homeassistant.components.climate import const as climate from homeassistant.components.google_assistant import const, error, helpers, trait +from homeassistant.components.humidifier import const as humidifier from homeassistant.config import async_process_ha_core_config from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -294,6 +295,33 @@ async def test_onoff_media_player(hass): assert off_calls[0].data == {ATTR_ENTITY_ID: "media_player.bla"} +async def test_onoff_humidifier(hass): + """Test OnOff trait support for humidifier domain.""" + assert helpers.get_google_type(humidifier.DOMAIN, None) is not None + assert trait.OnOffTrait.supported(humidifier.DOMAIN, 0, None) + + trt_on = trait.OnOffTrait(hass, State("humidifier.bla", STATE_ON), BASIC_CONFIG) + + assert trt_on.sync_attributes() == {} + + assert trt_on.query_attributes() == {"on": True} + + trt_off = trait.OnOffTrait(hass, State("humidifier.bla", STATE_OFF), BASIC_CONFIG) + + assert trt_off.query_attributes() == {"on": False} + + on_calls = async_mock_service(hass, humidifier.DOMAIN, SERVICE_TURN_ON) + await trt_on.execute(trait.COMMAND_ONOFF, BASIC_DATA, {"on": True}, {}) + assert len(on_calls) == 1 + assert on_calls[0].data == {ATTR_ENTITY_ID: "humidifier.bla"} + + off_calls = async_mock_service(hass, humidifier.DOMAIN, SERVICE_TURN_OFF) + + await trt_on.execute(trait.COMMAND_ONOFF, BASIC_DATA, {"on": False}, {}) + assert len(off_calls) == 1 + assert off_calls[0].data == {ATTR_ENTITY_ID: "humidifier.bla"} + + async def test_dock_vacuum(hass): """Test dock trait support for vacuum domain.""" assert helpers.get_google_type(vacuum.DOMAIN, None) is not None @@ -780,6 +808,42 @@ async def test_temperature_setting_climate_setpoint_auto(hass): assert calls[0].data == {ATTR_ENTITY_ID: "climate.bla", ATTR_TEMPERATURE: 19} +async def test_humidity_setting_humidifier_setpoint(hass): + """Test HumiditySetting trait support for humidifier domain - setpoint.""" + assert helpers.get_google_type(humidifier.DOMAIN, None) is not None + assert trait.HumiditySettingTrait.supported(humidifier.DOMAIN, 0, None) + + trt = trait.HumiditySettingTrait( + hass, + State( + "humidifier.bla", + STATE_ON, + { + humidifier.ATTR_MIN_HUMIDITY: 20, + humidifier.ATTR_MAX_HUMIDITY: 90, + humidifier.ATTR_HUMIDITY: 38, + }, + ), + BASIC_CONFIG, + ) + assert trt.sync_attributes() == { + "humiditySetpointRange": {"minPercent": 20, "maxPercent": 90} + } + assert trt.query_attributes() == { + "humiditySetpointPercent": 38, + } + assert trt.can_execute(trait.COMMAND_SET_HUMIDITY, {}) + + calls = async_mock_service(hass, humidifier.DOMAIN, humidifier.SERVICE_SET_HUMIDITY) + + await trt.execute(trait.COMMAND_SET_HUMIDITY, BASIC_DATA, {"humidity": 32}, {}) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: "humidifier.bla", + humidifier.ATTR_HUMIDITY: 32, + } + + async def test_lock_unlock_lock(hass): """Test LockUnlock trait locking support for lock domain.""" assert helpers.get_google_type(lock.DOMAIN, None) is not None @@ -1238,7 +1302,6 @@ async def test_fan_speed(hass): assert trt.query_attributes() == { "currentFanSpeedSetting": "low", "on": True, - "online": True, } assert trt.can_execute(trait.COMMAND_FANSPEED, params={"fanSpeed": "medium"}) @@ -1313,7 +1376,6 @@ async def test_modes_media_player(hass): assert trt.query_attributes() == { "currentModeSettings": {"input source": "game"}, "on": True, - "online": True, } assert trt.can_execute( @@ -1382,7 +1444,6 @@ async def test_modes_input_select(hass): assert trt.query_attributes() == { "currentModeSettings": {"option": "abc"}, "on": True, - "online": True, } assert trt.can_execute( @@ -1400,6 +1461,80 @@ async def test_modes_input_select(hass): assert calls[0].data == {"entity_id": "input_select.bla", "option": "xyz"} +async def test_modes_humidifier(hass): + """Test Humidifier Mode trait.""" + assert helpers.get_google_type(humidifier.DOMAIN, None) is not None + assert trait.ModesTrait.supported(humidifier.DOMAIN, humidifier.SUPPORT_MODES, None) + + trt = trait.ModesTrait( + hass, + State( + "humidifier.humidifier", + STATE_OFF, + attributes={ + humidifier.ATTR_AVAILABLE_MODES: [ + humidifier.MODE_NORMAL, + humidifier.MODE_AUTO, + humidifier.MODE_AWAY, + ], + ATTR_SUPPORTED_FEATURES: humidifier.SUPPORT_MODES, + humidifier.ATTR_MIN_HUMIDITY: 30, + humidifier.ATTR_MAX_HUMIDITY: 99, + humidifier.ATTR_HUMIDITY: 50, + humidifier.ATTR_MODE: humidifier.MODE_AUTO, + }, + ), + BASIC_CONFIG, + ) + + attribs = trt.sync_attributes() + assert attribs == { + "availableModes": [ + { + "name": "mode", + "name_values": [{"name_synonym": ["mode"], "lang": "en"}], + "settings": [ + { + "setting_name": "normal", + "setting_values": [ + {"setting_synonym": ["normal"], "lang": "en"} + ], + }, + { + "setting_name": "auto", + "setting_values": [{"setting_synonym": ["auto"], "lang": "en"}], + }, + { + "setting_name": "away", + "setting_values": [{"setting_synonym": ["away"], "lang": "en"}], + }, + ], + "ordered": False, + }, + ] + } + + assert trt.query_attributes() == { + "currentModeSettings": {"mode": "auto"}, + "on": False, + } + + assert trt.can_execute( + trait.COMMAND_MODES, params={"updateModeSettings": {"mode": "away"}} + ) + + calls = async_mock_service(hass, humidifier.DOMAIN, humidifier.SERVICE_SET_MODE) + await trt.execute( + trait.COMMAND_MODES, BASIC_DATA, {"updateModeSettings": {"mode": "away"}}, {}, + ) + + assert len(calls) == 1 + assert calls[0].data == { + "entity_id": "humidifier.humidifier", + "mode": "away", + } + + async def test_sound_modes(hass): """Test Mode trait.""" assert helpers.get_google_type(media_player.DOMAIN, None) is not None @@ -1450,7 +1585,6 @@ async def test_sound_modes(hass): assert trt.query_attributes() == { "currentModeSettings": {"sound mode": "stereo"}, "on": True, - "online": True, } assert trt.can_execute(