Add water_heater to google_assistant (#105915)

* Add water_heater to google_assistant

* Follow up comments

* Add water_heater to default exposed domains
This commit is contained in:
Jan Bouwhuis 2023-12-20 23:26:55 +01:00 committed by GitHub
parent c57cc85174
commit f5f9b89848
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 373 additions and 11 deletions

View file

@ -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 = {

View file

@ -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(

View file

@ -103,6 +103,7 @@
'sensor',
'switch',
'vacuum',
'water_heater',
]),
'project_id': '1234',
'report_state': False,

View file

@ -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