From f5f9b898480c051c7dd956b12428e3b099f67e61 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 20 Dec 2023 23:26:55 +0100 Subject: [PATCH] Add water_heater to google_assistant (#105915) * Add water_heater to google_assistant * Follow up comments * Add water_heater to default exposed domains --- .../components/google_assistant/const.py | 4 + .../components/google_assistant/trait.py | 140 +++++++++- .../snapshots/test_diagnostics.ambr | 1 + .../components/google_assistant/test_trait.py | 239 ++++++++++++++++++ 4 files changed, 373 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 060f7ce50e5..70bdc37df66 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -22,6 +22,7 @@ from homeassistant.components import ( sensor, switch, vacuum, + water_heater, ) DOMAIN = "google_assistant" @@ -64,6 +65,7 @@ DEFAULT_EXPOSED_DOMAINS = [ "sensor", "switch", "vacuum", + "water_heater", ] # https://developers.google.com/assistant/smarthome/guides @@ -93,6 +95,7 @@ TYPE_THERMOSTAT = f"{PREFIX_TYPES}THERMOSTAT" TYPE_TV = f"{PREFIX_TYPES}TV" TYPE_WINDOW = f"{PREFIX_TYPES}WINDOW" TYPE_VACUUM = f"{PREFIX_TYPES}VACUUM" +TYPE_WATERHEATER = f"{PREFIX_TYPES}WATERHEATER" SERVICE_REQUEST_SYNC = "request_sync" HOMEGRAPH_URL = "https://homegraph.googleapis.com/" @@ -147,6 +150,7 @@ DOMAIN_TO_GOOGLE_TYPES = { sensor.DOMAIN: TYPE_SENSOR, switch.DOMAIN: TYPE_SWITCH, vacuum.DOMAIN: TYPE_VACUUM, + water_heater.DOMAIN: TYPE_WATERHEATER, } DEVICE_CLASS_TO_GOOGLE_TYPES = { diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 33f0d7a3329..2e861f16a02 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -29,6 +29,7 @@ from homeassistant.components import ( sensor, switch, vacuum, + water_heater, ) from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature from homeassistant.components.camera import CameraEntityFeature @@ -40,6 +41,7 @@ from homeassistant.components.light import LightEntityFeature from homeassistant.components.lock import STATE_JAMMED, STATE_UNLOCKING from homeassistant.components.media_player import MediaPlayerEntityFeature, MediaType from homeassistant.components.vacuum import VacuumEntityFeature +from homeassistant.components.water_heater import WaterHeaterEntityFeature from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_BATTERY_LEVEL, @@ -139,6 +141,7 @@ COMMAND_PAUSEUNPAUSE = f"{PREFIX_COMMANDS}PauseUnpause" COMMAND_BRIGHTNESS_ABSOLUTE = f"{PREFIX_COMMANDS}BrightnessAbsolute" COMMAND_COLOR_ABSOLUTE = f"{PREFIX_COMMANDS}ColorAbsolute" COMMAND_ACTIVATE_SCENE = f"{PREFIX_COMMANDS}ActivateScene" +COMMAND_SET_TEMPERATURE = f"{PREFIX_COMMANDS}SetTemperature" COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT = ( f"{PREFIX_COMMANDS}ThermostatTemperatureSetpoint" ) @@ -417,6 +420,9 @@ class OnOffTrait(_Trait): @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" + if domain == water_heater.DOMAIN and features & WaterHeaterEntityFeature.ON_OFF: + return True + return domain in ( group.DOMAIN, input_boolean.DOMAIN, @@ -894,38 +900,97 @@ class StartStopTrait(_Trait): @register_trait class TemperatureControlTrait(_Trait): - """Trait for devices (other than thermostats) that support controlling temperature. Workaround for Temperature sensors. + """Trait for devices (other than thermostats) that support controlling temperature. + + Control the target temperature of water heaters. + Offers a workaround for Temperature sensors by setting queryOnlyTemperatureControl + in the response. https://developers.google.com/assistant/smarthome/traits/temperaturecontrol """ name = TRAIT_TEMPERATURE_CONTROL + commands = [ + COMMAND_SET_TEMPERATURE, + ] + @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" return ( + domain == water_heater.DOMAIN + and features & WaterHeaterEntityFeature.TARGET_TEMPERATURE + ) or ( domain == sensor.DOMAIN and device_class == sensor.SensorDeviceClass.TEMPERATURE ) def sync_attributes(self): """Return temperature attributes for a sync request.""" - return { - "temperatureUnitForUX": _google_temp_unit( - self.hass.config.units.temperature_unit - ), - "queryOnlyTemperatureControl": True, - "temperatureRange": { + response = {} + domain = self.state.domain + attrs = self.state.attributes + unit = self.hass.config.units.temperature_unit + response["temperatureUnitForUX"] = _google_temp_unit(unit) + + if domain == water_heater.DOMAIN: + min_temp = round( + TemperatureConverter.convert( + float(attrs[water_heater.ATTR_MIN_TEMP]), + unit, + UnitOfTemperature.CELSIUS, + ) + ) + max_temp = round( + TemperatureConverter.convert( + float(attrs[water_heater.ATTR_MAX_TEMP]), + unit, + UnitOfTemperature.CELSIUS, + ) + ) + response["temperatureRange"] = { + "minThresholdCelsius": min_temp, + "maxThresholdCelsius": max_temp, + } + else: + response["queryOnlyTemperatureControl"] = True + response["temperatureRange"] = { "minThresholdCelsius": -100, "maxThresholdCelsius": 100, - }, - } + } + + return response def query_attributes(self): """Return temperature states.""" response = {} + domain = self.state.domain unit = self.hass.config.units.temperature_unit + if domain == water_heater.DOMAIN: + target_temp = self.state.attributes[water_heater.ATTR_TEMPERATURE] + current_temp = self.state.attributes[water_heater.ATTR_CURRENT_TEMPERATURE] + if target_temp not in (STATE_UNKNOWN, STATE_UNAVAILABLE): + response["temperatureSetpointCelsius"] = round( + TemperatureConverter.convert( + float(target_temp), + unit, + UnitOfTemperature.CELSIUS, + ), + 1, + ) + if current_temp is not None: + response["temperatureAmbientCelsius"] = round( + TemperatureConverter.convert( + float(current_temp), + unit, + UnitOfTemperature.CELSIUS, + ), + 1, + ) + return response + + # domain == sensor.DOMAIN current_temp = self.state.state if current_temp not in (STATE_UNKNOWN, STATE_UNAVAILABLE): temp = round( @@ -940,8 +1005,35 @@ class TemperatureControlTrait(_Trait): return response async def execute(self, command, data, params, challenge): - """Unsupported.""" - raise SmartHomeError(ERR_NOT_SUPPORTED, "Execute is not supported by sensor") + """Execute a temperature point or mode command.""" + # All sent in temperatures are always in Celsius + domain = self.state.domain + unit = self.hass.config.units.temperature_unit + + if domain == water_heater.DOMAIN and command == COMMAND_SET_TEMPERATURE: + min_temp = self.state.attributes[water_heater.ATTR_MIN_TEMP] + max_temp = self.state.attributes[water_heater.ATTR_MAX_TEMP] + temp = TemperatureConverter.convert( + params["temperature"], UnitOfTemperature.CELSIUS, unit + ) + if unit == UnitOfTemperature.FAHRENHEIT: + temp = round(temp) + if temp < min_temp or temp > max_temp: + raise SmartHomeError( + ERR_VALUE_OUT_OF_RANGE, + f"Temperature should be between {min_temp} and {max_temp}", + ) + + await self.hass.services.async_call( + water_heater.DOMAIN, + water_heater.SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: self.state.entity_id, ATTR_TEMPERATURE: temp}, + blocking=not self.config.should_report_state, + context=data.context, + ) + return + + raise SmartHomeError(ERR_NOT_SUPPORTED, f"Execute is not supported by {domain}") @register_trait @@ -1696,6 +1788,12 @@ class ModesTrait(_Trait): if domain == light.DOMAIN and features & LightEntityFeature.EFFECT: return True + if ( + domain == water_heater.DOMAIN + and features & WaterHeaterEntityFeature.OPERATION_MODE + ): + return True + if domain != media_player.DOMAIN: return False @@ -1736,6 +1834,7 @@ class ModesTrait(_Trait): (select.DOMAIN, select.ATTR_OPTIONS, "option"), (humidifier.DOMAIN, humidifier.ATTR_AVAILABLE_MODES, "mode"), (light.DOMAIN, light.ATTR_EFFECT_LIST, "effect"), + (water_heater.DOMAIN, water_heater.ATTR_OPERATION_LIST, "operation mode"), ): if self.state.domain != domain: continue @@ -1769,6 +1868,11 @@ class ModesTrait(_Trait): elif self.state.domain == humidifier.DOMAIN: if ATTR_MODE in attrs: mode_settings["mode"] = attrs.get(ATTR_MODE) + elif self.state.domain == water_heater.DOMAIN: + if water_heater.ATTR_OPERATION_MODE in attrs: + mode_settings["operation mode"] = attrs.get( + water_heater.ATTR_OPERATION_MODE + ) elif self.state.domain == light.DOMAIN and ( effect := attrs.get(light.ATTR_EFFECT) ): @@ -1840,6 +1944,20 @@ class ModesTrait(_Trait): ) return + if self.state.domain == water_heater.DOMAIN: + requested_mode = settings["operation mode"] + await self.hass.services.async_call( + water_heater.DOMAIN, + water_heater.SERVICE_SET_OPERATION_MODE, + { + water_heater.ATTR_OPERATION_MODE: requested_mode, + ATTR_ENTITY_ID: self.state.entity_id, + }, + blocking=not self.config.should_report_state, + context=data.context, + ) + return + if self.state.domain == light.DOMAIN: requested_effect = settings["effect"] await self.hass.services.async_call( diff --git a/tests/components/google_assistant/snapshots/test_diagnostics.ambr b/tests/components/google_assistant/snapshots/test_diagnostics.ambr index 663979eda77..e29b4d5f487 100644 --- a/tests/components/google_assistant/snapshots/test_diagnostics.ambr +++ b/tests/components/google_assistant/snapshots/test_diagnostics.ambr @@ -103,6 +103,7 @@ 'sensor', 'switch', 'vacuum', + 'water_heater', ]), 'project_id': '1234', 'report_state': False, diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 903ba5ca036..293b16e637a 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -27,6 +27,7 @@ from homeassistant.components import ( sensor, switch, vacuum, + water_heater, ) from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature from homeassistant.components.camera import CameraEntityFeature @@ -44,6 +45,7 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.components.vacuum import VacuumEntityFeature +from homeassistant.components.water_heater import WaterHeaterEntityFeature from homeassistant.config import async_process_ha_core_config from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -75,6 +77,7 @@ from homeassistant.core import ( State, ) from homeassistant.util import color +from homeassistant.util.unit_conversion import TemperatureConverter from . import BASIC_CONFIG, MockConfig @@ -393,6 +396,35 @@ async def test_onoff_humidifier(hass: HomeAssistant) -> None: assert off_calls[0].data == {ATTR_ENTITY_ID: "humidifier.bla"} +async def test_onoff_water_heater(hass: HomeAssistant) -> None: + """Test OnOff trait support for water_heater domain.""" + assert helpers.get_google_type(water_heater.DOMAIN, None) is not None + assert trait.OnOffTrait.supported( + water_heater.DOMAIN, WaterHeaterEntityFeature.ON_OFF, None, None + ) + + trt_on = trait.OnOffTrait(hass, State("water_heater.bla", STATE_ON), BASIC_CONFIG) + + assert trt_on.sync_attributes() == {} + + assert trt_on.query_attributes() == {"on": True} + + trt_off = trait.OnOffTrait(hass, State("water_heater.bla", STATE_OFF), BASIC_CONFIG) + + assert trt_off.query_attributes() == {"on": False} + + on_calls = async_mock_service(hass, water_heater.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: "water_heater.bla"} + + off_calls = async_mock_service(hass, water_heater.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: "water_heater.bla"} + + async def test_dock_vacuum(hass: HomeAssistant) -> None: """Test dock trait support for vacuum domain.""" assert helpers.get_google_type(vacuum.DOMAIN, None) is not None @@ -1246,6 +1278,135 @@ async def test_temperature_control(hass: HomeAssistant) -> None: assert err.value.code == const.ERR_NOT_SUPPORTED +@pytest.mark.parametrize( + ("unit_in", "unit_out", "temp_in", "temp_out", "current_in", "current_out"), + [ + (UnitOfTemperature.CELSIUS, "C", "120", 120, "130", 130), + (UnitOfTemperature.FAHRENHEIT, "F", "248", 120, "266", 130), + ], +) +async def test_temperature_control_water_heater( + hass: HomeAssistant, + unit_in: UnitOfTemperature, + unit_out: str, + temp_in: str, + temp_out: float, + current_in: str, + current_out: float, +) -> None: + """Test TemperatureControl trait support for water heater domain.""" + hass.config.units.temperature_unit = unit_in + + min_temp = TemperatureConverter.convert( + water_heater.DEFAULT_MIN_TEMP, + UnitOfTemperature.CELSIUS, + unit_in, + ) + max_temp = TemperatureConverter.convert( + water_heater.DEFAULT_MAX_TEMP, + UnitOfTemperature.CELSIUS, + unit_in, + ) + + trt = trait.TemperatureControlTrait( + hass, + State( + "water_heater.bla", + "attributes", + { + "min_temp": min_temp, + "max_temp": max_temp, + "temperature": temp_in, + "current_temperature": current_in, + }, + ), + BASIC_CONFIG, + ) + + assert trt.sync_attributes() == { + "temperatureUnitForUX": unit_out, + "temperatureRange": { + "maxThresholdCelsius": water_heater.DEFAULT_MAX_TEMP, + "minThresholdCelsius": water_heater.DEFAULT_MIN_TEMP, + }, + } + assert trt.query_attributes() == { + "temperatureSetpointCelsius": temp_out, + "temperatureAmbientCelsius": current_out, + } + + +@pytest.mark.parametrize( + ("unit", "temp_init", "temp_in", "temp_out", "current_init"), + [ + (UnitOfTemperature.CELSIUS, "180", 220, 220, "180"), + (UnitOfTemperature.FAHRENHEIT, "356", 220, 428, "356"), + ], +) +async def test_temperature_control_water_heater_set_temperature( + hass: HomeAssistant, + unit: UnitOfTemperature, + temp_init: str, + temp_in: float, + temp_out: float, + current_init: str, +) -> None: + """Test TemperatureControl trait support for water heater domain - SetTemperature.""" + hass.config.units.temperature_unit = unit + + min_temp = TemperatureConverter.convert( + 40, + UnitOfTemperature.CELSIUS, + unit, + ) + max_temp = TemperatureConverter.convert( + 230, + UnitOfTemperature.CELSIUS, + unit, + ) + + trt = trait.TemperatureControlTrait( + hass, + State( + "water_heater.bla", + "attributes", + { + "min_temp": min_temp, + "max_temp": max_temp, + "temperature": temp_init, + "current_temperature": current_init, + }, + ), + BASIC_CONFIG, + ) + + assert trt.can_execute(trait.COMMAND_SET_TEMPERATURE, {}) + + calls = async_mock_service( + hass, water_heater.DOMAIN, water_heater.SERVICE_SET_TEMPERATURE + ) + + with pytest.raises(helpers.SmartHomeError): + await trt.execute( + trait.COMMAND_SET_TEMPERATURE, + BASIC_DATA, + {"temperature": -100}, + {}, + ) + + await trt.execute( + trait.COMMAND_SET_TEMPERATURE, + BASIC_DATA, + {"temperature": temp_in}, + {}, + ) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: "water_heater.bla", + ATTR_TEMPERATURE: temp_out, + } + + async def test_humidity_setting_humidifier_setpoint(hass: HomeAssistant) -> None: """Test HumiditySetting trait support for humidifier domain - setpoint.""" assert helpers.get_google_type(humidifier.DOMAIN, None) is not None @@ -2411,6 +2572,84 @@ async def test_modes_humidifier(hass: HomeAssistant) -> None: } +async def test_modes_water_heater(hass: HomeAssistant) -> None: + """Test Humidifier Mode trait.""" + assert helpers.get_google_type(water_heater.DOMAIN, None) is not None + assert trait.ModesTrait.supported( + water_heater.DOMAIN, WaterHeaterEntityFeature.OPERATION_MODE, None, None + ) + + trt = trait.ModesTrait( + hass, + State( + "water_heater.water_heater", + STATE_OFF, + attributes={ + water_heater.ATTR_OPERATION_LIST: [ + water_heater.STATE_ECO, + water_heater.STATE_HEAT_PUMP, + water_heater.STATE_GAS, + ], + ATTR_SUPPORTED_FEATURES: WaterHeaterEntityFeature.OPERATION_MODE, + water_heater.ATTR_OPERATION_MODE: water_heater.STATE_HEAT_PUMP, + }, + ), + BASIC_CONFIG, + ) + + attribs = trt.sync_attributes() + assert attribs == { + "availableModes": [ + { + "name": "operation mode", + "name_values": [{"name_synonym": ["operation mode"], "lang": "en"}], + "settings": [ + { + "setting_name": "eco", + "setting_values": [{"setting_synonym": ["eco"], "lang": "en"}], + }, + { + "setting_name": "heat_pump", + "setting_values": [ + {"setting_synonym": ["heat_pump"], "lang": "en"} + ], + }, + { + "setting_name": "gas", + "setting_values": [{"setting_synonym": ["gas"], "lang": "en"}], + }, + ], + "ordered": False, + }, + ] + } + + assert trt.query_attributes() == { + "currentModeSettings": {"operation mode": "heat_pump"}, + "on": False, + } + + assert trt.can_execute( + trait.COMMAND_MODES, params={"updateModeSettings": {"operation mode": "gas"}} + ) + + calls = async_mock_service( + hass, water_heater.DOMAIN, water_heater.SERVICE_SET_OPERATION_MODE + ) + await trt.execute( + trait.COMMAND_MODES, + BASIC_DATA, + {"updateModeSettings": {"operation mode": "gas"}}, + {}, + ) + + assert len(calls) == 1 + assert calls[0].data == { + "entity_id": "water_heater.water_heater", + "operation_mode": "gas", + } + + async def test_sound_modes(hass: HomeAssistant) -> None: """Test Mode trait.""" assert helpers.get_google_type(media_player.DOMAIN, None) is not None