Add water_heater to alexa (#106011)

* Add water_heater support to alexa

* Improve test coverage

* Follow up comments
This commit is contained in:
Jan Bouwhuis 2023-12-20 16:48:02 +01:00 committed by GitHub
parent 5a3db078d5
commit 93c800c4e8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 424 additions and 14 deletions

View File

@ -19,6 +19,7 @@ from homeassistant.components import (
number,
timer,
vacuum,
water_heater,
)
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntityFeature,
@ -435,7 +436,8 @@ class AlexaPowerController(AlexaCapability):
is_on = self.entity.state == vacuum.STATE_CLEANING
elif self.entity.domain == timer.DOMAIN:
is_on = self.entity.state != STATE_IDLE
elif self.entity.domain == water_heater.DOMAIN:
is_on = self.entity.state not in (STATE_OFF, STATE_UNKNOWN)
else:
is_on = self.entity.state != STATE_OFF
@ -938,6 +940,9 @@ class AlexaTemperatureSensor(AlexaCapability):
if self.entity.domain == climate.DOMAIN:
unit = self.hass.config.units.temperature_unit
temp = self.entity.attributes.get(climate.ATTR_CURRENT_TEMPERATURE)
elif self.entity.domain == water_heater.DOMAIN:
unit = self.hass.config.units.temperature_unit
temp = self.entity.attributes.get(water_heater.ATTR_CURRENT_TEMPERATURE)
if temp is None or temp in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return None
@ -1108,6 +1113,8 @@ class AlexaThermostatController(AlexaCapability):
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if supported & climate.ClimateEntityFeature.TARGET_TEMPERATURE:
properties.append({"name": "targetSetpoint"})
if supported & water_heater.WaterHeaterEntityFeature.TARGET_TEMPERATURE:
properties.append({"name": "targetSetpoint"})
if supported & climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE:
properties.append({"name": "lowerSetpoint"})
properties.append({"name": "upperSetpoint"})
@ -1127,6 +1134,8 @@ class AlexaThermostatController(AlexaCapability):
return None
if name == "thermostatMode":
if self.entity.domain == water_heater.DOMAIN:
return None
preset = self.entity.attributes.get(climate.ATTR_PRESET_MODE)
mode: dict[str, str] | str | None
@ -1176,9 +1185,13 @@ class AlexaThermostatController(AlexaCapability):
ThermostatMode Values.
ThermostatMode Value must be AUTO, COOL, HEAT, ECO, OFF, or CUSTOM.
Water heater devices do not return thermostat modes.
"""
if self.entity.domain == water_heater.DOMAIN:
return None
supported_modes: list[str] = []
hvac_modes = self.entity.attributes[climate.ATTR_HVAC_MODES]
hvac_modes = self.entity.attributes.get(climate.ATTR_HVAC_MODES, [])
for mode in hvac_modes:
if thermostat_mode := API_THERMOSTAT_MODES.get(mode):
supported_modes.append(thermostat_mode)
@ -1408,6 +1421,16 @@ class AlexaModeController(AlexaCapability):
if mode in self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES, []):
return f"{humidifier.ATTR_MODE}.{mode}"
# Water heater operation mode
if self.instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}":
operation_mode = self.entity.attributes.get(
water_heater.ATTR_OPERATION_MODE, None
)
if operation_mode in self.entity.attributes.get(
water_heater.ATTR_OPERATION_LIST, []
):
return f"{water_heater.ATTR_OPERATION_MODE}.{operation_mode}"
# Cover Position
if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
# Return state instead of position when using ModeController.
@ -1478,6 +1501,26 @@ class AlexaModeController(AlexaCapability):
)
return self._resource.serialize_capability_resources()
# Water heater operation modes
if self.instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}":
self._resource = AlexaModeResource([AlexaGlobalCatalog.SETTING_MODE], False)
operation_modes = self.entity.attributes.get(
water_heater.ATTR_OPERATION_LIST, []
)
for operation_mode in operation_modes:
self._resource.add_mode(
f"{water_heater.ATTR_OPERATION_MODE}.{operation_mode}",
[operation_mode],
)
# Devices with a single mode completely break Alexa discovery,
# add a fake preset (see issue #53832).
if len(operation_modes) == 1:
self._resource.add_mode(
f"{water_heater.ATTR_OPERATION_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(

View File

@ -32,6 +32,7 @@ from homeassistant.components import (
switch,
timer,
vacuum,
water_heater,
)
from homeassistant.const import (
ATTR_DEVICE_CLASS,
@ -248,6 +249,9 @@ class DisplayCategory:
# Indicates a vacuum cleaner.
VACUUM_CLEANER = "VACUUM_CLEANER"
# Indicates a water heater.
WATER_HEATER = "WATER_HEATER"
# Indicates a network-connected wearable device, such as an Apple Watch,
# Fitbit, or Samsung Gear.
WEARABLE = "WEARABLE"
@ -456,23 +460,46 @@ class ButtonCapabilities(AlexaEntity):
@ENTITY_ADAPTERS.register(climate.DOMAIN)
@ENTITY_ADAPTERS.register(water_heater.DOMAIN)
class ClimateCapabilities(AlexaEntity):
"""Class to represent Climate capabilities."""
def default_display_categories(self) -> list[str]:
"""Return the display categories for this entity."""
if self.entity.domain == water_heater.DOMAIN:
return [DisplayCategory.WATER_HEATER]
return [DisplayCategory.THERMOSTAT]
def interfaces(self) -> Generator[AlexaCapability, None, None]:
"""Yield the supported interfaces."""
# If we support two modes, one being off, we allow turning on too.
if climate.HVACMode.OFF in self.entity.attributes.get(
climate.ATTR_HVAC_MODES, []
supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if (
self.entity.domain == climate.DOMAIN
and climate.HVACMode.OFF
in self.entity.attributes.get(climate.ATTR_HVAC_MODES, [])
or self.entity.domain == water_heater.DOMAIN
and (supported_features & water_heater.WaterHeaterEntityFeature.ON_OFF)
):
yield AlexaPowerController(self.entity)
yield AlexaThermostatController(self.hass, self.entity)
yield AlexaTemperatureSensor(self.hass, self.entity)
if (
self.entity.domain == climate.DOMAIN
or self.entity.domain == water_heater.DOMAIN
and (
supported_features
& water_heater.WaterHeaterEntityFeature.OPERATION_MODE
)
):
yield AlexaThermostatController(self.hass, self.entity)
yield AlexaTemperatureSensor(self.hass, self.entity)
if self.entity.domain == water_heater.DOMAIN and (
supported_features & water_heater.WaterHeaterEntityFeature.OPERATION_MODE
):
yield AlexaModeController(
self.entity,
instance=f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}",
)
yield AlexaEndpointHealth(self.hass, self.entity)
yield Alexa(self.entity)

View File

@ -22,6 +22,7 @@ from homeassistant.components import (
number,
timer,
vacuum,
water_heater,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
@ -80,6 +81,23 @@ from .state_report import AlexaDirective, AlexaResponse, async_enable_proactive_
_LOGGER = logging.getLogger(__name__)
DIRECTIVE_NOT_SUPPORTED = "Entity does not support directive"
MIN_MAX_TEMP = {
climate.DOMAIN: {
"min_temp": climate.ATTR_MIN_TEMP,
"max_temp": climate.ATTR_MAX_TEMP,
},
water_heater.DOMAIN: {
"min_temp": water_heater.ATTR_MIN_TEMP,
"max_temp": water_heater.ATTR_MAX_TEMP,
},
}
SERVICE_SET_TEMPERATURE = {
climate.DOMAIN: climate.SERVICE_SET_TEMPERATURE,
water_heater.DOMAIN: water_heater.SERVICE_SET_TEMPERATURE,
}
HANDLERS: Registry[
tuple[str, str],
Callable[
@ -804,8 +822,10 @@ async def async_api_set_target_temp(
) -> AlexaResponse:
"""Process a set target temperature request."""
entity = directive.entity
min_temp = entity.attributes[climate.ATTR_MIN_TEMP]
max_temp = entity.attributes[climate.ATTR_MAX_TEMP]
domain = entity.domain
min_temp = entity.attributes[MIN_MAX_TEMP[domain]["min_temp"]]
max_temp = entity.attributes["max_temp"]
unit = hass.config.units.temperature_unit
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
@ -849,9 +869,11 @@ async def async_api_set_target_temp(
}
)
service = SERVICE_SET_TEMPERATURE[domain]
await hass.services.async_call(
entity.domain,
climate.SERVICE_SET_TEMPERATURE,
service,
data,
blocking=False,
context=context,
@ -867,11 +889,12 @@ async def async_api_adjust_target_temp(
directive: AlexaDirective,
context: ha.Context,
) -> AlexaResponse:
"""Process an adjust target temperature request."""
"""Process an adjust target temperature request for climates and water heaters."""
data: dict[str, Any]
entity = directive.entity
min_temp = entity.attributes[climate.ATTR_MIN_TEMP]
max_temp = entity.attributes[climate.ATTR_MAX_TEMP]
domain = entity.domain
min_temp = entity.attributes[MIN_MAX_TEMP[domain]["min_temp"]]
max_temp = entity.attributes[MIN_MAX_TEMP[domain]["max_temp"]]
unit = hass.config.units.temperature_unit
temp_delta = temperature_from_object(
@ -932,9 +955,11 @@ async def async_api_adjust_target_temp(
}
)
service = SERVICE_SET_TEMPERATURE[domain]
await hass.services.async_call(
entity.domain,
climate.SERVICE_SET_TEMPERATURE,
service,
data,
blocking=False,
context=context,
@ -1163,6 +1188,23 @@ async def async_api_set_mode(
msg = f"Entity '{entity.entity_id}' does not support Mode '{mode}'"
raise AlexaInvalidValueError(msg)
# Water heater operation mode
elif instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}":
operation_mode = mode.split(".")[1]
operation_modes: list[str] | None = entity.attributes.get(
water_heater.ATTR_OPERATION_LIST
)
if (
operation_mode != PRESET_MODE_NA
and operation_modes
and operation_mode in operation_modes
):
service = water_heater.SERVICE_SET_OPERATION_MODE
data[water_heater.ATTR_OPERATION_MODE] = operation_mode
else:
msg = f"Entity '{entity.entity_id}' does not support Operation mode '{operation_mode}'"
raise AlexaInvalidValueError(msg)
# Cover Position
elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
position = mode.split(".")[1]

View File

@ -8,6 +8,13 @@ from homeassistant.components.alexa import smart_home
from homeassistant.components.climate import ATTR_CURRENT_TEMPERATURE, HVACMode
from homeassistant.components.lock import STATE_JAMMED, STATE_LOCKING, STATE_UNLOCKING
from homeassistant.components.media_player import MediaPlayerEntityFeature
from homeassistant.components.water_heater import (
ATTR_OPERATION_LIST,
ATTR_OPERATION_MODE,
STATE_ECO,
STATE_GAS,
STATE_HEAT_PUMP,
)
from homeassistant.const import (
ATTR_UNIT_OF_MEASUREMENT,
STATE_ALARM_ARMED_AWAY,
@ -16,6 +23,7 @@ from homeassistant.const import (
STATE_ALARM_ARMED_NIGHT,
STATE_ALARM_DISARMED,
STATE_LOCKED,
STATE_OFF,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
STATE_UNLOCKED,
@ -777,6 +785,96 @@ async def test_report_climate_state(hass: HomeAssistant) -> None:
assert msg["event"]["payload"]["type"] == "INTERNAL_ERROR"
async def test_report_water_heater_state(hass: HomeAssistant) -> None:
"""Test ThermostatController also reports state correctly for water heaters."""
for operation_mode in (STATE_ECO, STATE_GAS, STATE_HEAT_PUMP):
hass.states.async_set(
"water_heater.boyler",
operation_mode,
{
"friendly_name": "Boyler",
"supported_features": 11,
ATTR_CURRENT_TEMPERATURE: 34,
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS,
ATTR_OPERATION_LIST: [STATE_ECO, STATE_GAS, STATE_HEAT_PUMP],
ATTR_OPERATION_MODE: operation_mode,
},
)
properties = await reported_properties(hass, "water_heater.boyler")
properties.assert_not_has_property(
"Alexa.ThermostatController", "thermostatMode"
)
properties.assert_equal(
"Alexa.ModeController", "mode", f"operation_mode.{operation_mode}"
)
properties.assert_equal(
"Alexa.TemperatureSensor",
"temperature",
{"value": 34.0, "scale": "CELSIUS"},
)
for off_mode in [STATE_OFF]:
hass.states.async_set(
"water_heater.boyler",
off_mode,
{
"friendly_name": "Boyler",
"supported_features": 11,
ATTR_CURRENT_TEMPERATURE: 34,
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS,
},
)
properties = await reported_properties(hass, "water_heater.boyler")
properties.assert_not_has_property(
"Alexa.ThermostatController", "thermostatMode"
)
properties.assert_not_has_property("Alexa.ModeController", "mode")
properties.assert_equal(
"Alexa.TemperatureSensor",
"temperature",
{"value": 34.0, "scale": "CELSIUS"},
)
for state in "unavailable", "unknown":
hass.states.async_set(
f"water_heater.{state}",
state,
{"friendly_name": f"Boyler {state}", "supported_features": 11},
)
properties = await reported_properties(hass, f"water_heater.{state}")
properties.assert_not_has_property(
"Alexa.ThermostatController", "thermostatMode"
)
properties.assert_not_has_property("Alexa.ModeController", "mode")
async def test_report_singe_mode_water_heater(hass: HomeAssistant) -> None:
"""Test ThermostatController also reports state correctly for water heaters."""
operation_mode = STATE_ECO
hass.states.async_set(
"water_heater.boyler",
operation_mode,
{
"friendly_name": "Boyler",
"supported_features": 11,
ATTR_CURRENT_TEMPERATURE: 34,
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS,
ATTR_OPERATION_LIST: [STATE_ECO],
ATTR_OPERATION_MODE: operation_mode,
},
)
properties = await reported_properties(hass, "water_heater.boyler")
properties.assert_not_has_property("Alexa.ThermostatController", "thermostatMode")
properties.assert_equal(
"Alexa.ModeController", "mode", f"operation_mode.{operation_mode}"
)
properties.assert_equal(
"Alexa.TemperatureSensor",
"temperature",
{"value": 34.0, "scale": "CELSIUS"},
)
async def test_temperature_sensor_sensor(hass: HomeAssistant) -> None:
"""Test TemperatureSensor reports sensor temperature correctly."""
for bad_value in (STATE_UNKNOWN, STATE_UNAVAILABLE, "not-number"):
@ -823,6 +921,29 @@ async def test_temperature_sensor_climate(hass: HomeAssistant) -> None:
)
async def test_temperature_sensor_water_heater(hass: HomeAssistant) -> None:
"""Test TemperatureSensor reports climate temperature correctly."""
for bad_value in (STATE_UNKNOWN, STATE_UNAVAILABLE, "not-number"):
hass.states.async_set(
"water_heater.boyler",
STATE_ECO,
{"supported_features": 11, ATTR_CURRENT_TEMPERATURE: bad_value},
)
properties = await reported_properties(hass, "water_heater.boyler")
properties.assert_not_has_property("Alexa.TemperatureSensor", "temperature")
hass.states.async_set(
"water_heater.boyler",
STATE_ECO,
{"supported_features": 11, ATTR_CURRENT_TEMPERATURE: 34},
)
properties = await reported_properties(hass, "water_heater.boyler")
properties.assert_equal(
"Alexa.TemperatureSensor", "temperature", {"value": 34.0, "scale": "CELSIUS"}
)
async def test_report_alarm_control_panel_state(hass: HomeAssistant) -> None:
"""Test SecurityPanelController implements armState property."""
hass.states.async_set("alarm_control_panel.armed_away", STATE_ALARM_ARMED_AWAY, {})

View File

@ -128,12 +128,14 @@ async def assert_request_calls_service(
async def assert_request_fails(
namespace, name, endpoint, service_not_called, hass, payload=None
namespace, name, endpoint, service_not_called, hass, payload=None, instance=None
):
"""Assert an API request returns an ErrorResponse."""
request = get_new_request(namespace, name, endpoint)
if payload:
request["directive"]["payload"] = payload
if instance:
request["directive"]["header"]["instance"] = instance
domain, service_name = service_not_called.split(".")
call = async_mock_service(hass, domain, service_name)

View File

@ -2700,6 +2700,181 @@ async def test_thermostat(hass: HomeAssistant) -> None:
assert call.data["preset_mode"] == "eco"
async def test_water_heater(hass: HomeAssistant) -> None:
"""Test water_heater discovery."""
hass.config.units = US_CUSTOMARY_SYSTEM
device = (
"water_heater.boyler",
"gas",
{
"temperature": 70.0,
"target_temp_high": None,
"target_temp_low": None,
"current_temperature": 75.0,
"friendly_name": "Test water heater",
"supported_features": 1 | 2 | 8,
"operation_list": ["off", "gas", "eco"],
"operation_mode": "gas",
"min_temp": 50,
"max_temp": 90,
},
)
appliance = await discovery_test(device, hass)
assert appliance["endpointId"] == "water_heater#boyler"
assert appliance["displayCategories"][0] == "WATER_HEATER"
assert appliance["friendlyName"] == "Test water heater"
capabilities = assert_endpoint_capabilities(
appliance,
"Alexa.PowerController",
"Alexa.ThermostatController",
"Alexa.ModeController",
"Alexa.TemperatureSensor",
"Alexa.EndpointHealth",
"Alexa",
)
properties = await reported_properties(hass, "water_heater#boyler")
properties.assert_equal("Alexa.ModeController", "mode", "operation_mode.gas")
properties.assert_equal(
"Alexa.ThermostatController",
"targetSetpoint",
{"value": 70.0, "scale": "FAHRENHEIT"},
)
properties.assert_equal(
"Alexa.TemperatureSensor", "temperature", {"value": 75.0, "scale": "FAHRENHEIT"}
)
modes_capability = get_capability(capabilities, "Alexa.ModeController")
assert modes_capability is not None
configuration = modes_capability["configuration"]
supported_modes = ["operation_mode.off", "operation_mode.gas", "operation_mode.eco"]
for mode in supported_modes:
assert mode in [item["value"] for item in configuration["supportedModes"]]
call, msg = await assert_request_calls_service(
"Alexa.ThermostatController",
"SetTargetTemperature",
"water_heater#boyler",
"water_heater.set_temperature",
hass,
payload={"targetSetpoint": {"value": 69.0, "scale": "FAHRENHEIT"}},
)
assert call.data["temperature"] == 69.0
properties = ReportedProperties(msg["context"]["properties"])
properties.assert_equal(
"Alexa.ThermostatController",
"targetSetpoint",
{"value": 69.0, "scale": "FAHRENHEIT"},
)
msg = await assert_request_fails(
"Alexa.ThermostatController",
"SetTargetTemperature",
"water_heater#boyler",
"water_heater.set_temperature",
hass,
payload={"targetSetpoint": {"value": 0.0, "scale": "CELSIUS"}},
)
assert msg["event"]["payload"]["type"] == "TEMPERATURE_VALUE_OUT_OF_RANGE"
call, msg = await assert_request_calls_service(
"Alexa.ThermostatController",
"SetTargetTemperature",
"water_heater#boyler",
"water_heater.set_temperature",
hass,
payload={
"targetSetpoint": {"value": 30.0, "scale": "CELSIUS"},
},
)
assert call.data["temperature"] == 86.0
properties = ReportedProperties(msg["context"]["properties"])
properties.assert_equal(
"Alexa.ThermostatController",
"targetSetpoint",
{"value": 86.0, "scale": "FAHRENHEIT"},
)
call, msg = await assert_request_calls_service(
"Alexa.ThermostatController",
"AdjustTargetTemperature",
"water_heater#boyler",
"water_heater.set_temperature",
hass,
payload={"targetSetpointDelta": {"value": -10.0, "scale": "KELVIN"}},
)
assert call.data["temperature"] == 52.0
properties = ReportedProperties(msg["context"]["properties"])
properties.assert_equal(
"Alexa.ThermostatController",
"targetSetpoint",
{"value": 52.0, "scale": "FAHRENHEIT"},
)
msg = await assert_request_fails(
"Alexa.ThermostatController",
"AdjustTargetTemperature",
"water_heater#boyler",
"water_heater.set_temperature",
hass,
payload={"targetSetpointDelta": {"value": 20.0, "scale": "CELSIUS"}},
)
assert msg["event"]["payload"]["type"] == "TEMPERATURE_VALUE_OUT_OF_RANGE"
# Setting mode, the payload can be an object with a value attribute...
call, msg = await assert_request_calls_service(
"Alexa.ModeController",
"SetMode",
"water_heater#boyler",
"water_heater.set_operation_mode",
hass,
payload={"mode": "operation_mode.eco"},
instance="water_heater.operation_mode",
)
assert call.data["operation_mode"] == "eco"
properties = ReportedProperties(msg["context"]["properties"])
properties.assert_equal("Alexa.ModeController", "mode", "operation_mode.eco")
call, msg = await assert_request_calls_service(
"Alexa.ModeController",
"SetMode",
"water_heater#boyler",
"water_heater.set_operation_mode",
hass,
payload={"mode": "operation_mode.gas"},
instance="water_heater.operation_mode",
)
assert call.data["operation_mode"] == "gas"
properties = ReportedProperties(msg["context"]["properties"])
properties.assert_equal("Alexa.ModeController", "mode", "operation_mode.gas")
# assert unsupported mode
msg = await assert_request_fails(
"Alexa.ModeController",
"SetMode",
"water_heater#boyler",
"water_heater.set_operation_mode",
hass,
payload={"mode": "operation_mode.invalid"},
instance="water_heater.operation_mode",
)
assert msg["event"]["payload"]["type"] == "INVALID_VALUE"
call, _ = await assert_request_calls_service(
"Alexa.ModeController",
"SetMode",
"water_heater#boyler",
"water_heater.set_operation_mode",
hass,
payload={"mode": "operation_mode.off"},
instance="water_heater.operation_mode",
)
assert call.data["operation_mode"] == "off"
async def test_no_current_target_temp_adjusting_temp(hass: HomeAssistant) -> None:
"""Test thermostat adjusting temp with no initial target temperature."""
hass.config.units = US_CUSTOMARY_SYSTEM