Skip to content
Closed
57 changes: 54 additions & 3 deletions homeassistant/components/water_heater/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,13 +423,64 @@ async def async_service_temperature_set(
) -> None:
"""Handle set temperature service."""
hass = entity.hass
kwargs = {}
kwargs: dict[str, Any] = {}

min_temp = entity.min_temp
max_temp = entity.max_temp
min_temp_displayed = (
show_temp(hass, min_temp, entity.temperature_unit, entity.precision)
if min_temp is not None
else None
)
max_temp_displayed = (
show_temp(hass, max_temp, entity.temperature_unit, entity.precision)
if max_temp is not None
else None
)

for value, temp in service.data.items():
if value in CONVERTIBLE_ATTRIBUTE:
kwargs[value] = TemperatureConverter.convert(
temp, hass.config.units.temperature_unit, entity.temperature_unit
if (
min_temp_displayed is not None
and temp == min_temp_displayed
and min_temp is not None
):
check_temp = min_temp
elif (
max_temp_displayed is not None
and temp == max_temp_displayed
and max_temp is not None
):
check_temp = max_temp
else:
check_temp = TemperatureConverter.convert(
temp, hass.config.units.temperature_unit, entity.temperature_unit
)
_LOGGER.debug(
"Check valid temperature %s %s (%s %s) in range %s %s - %s %s",
check_temp,
entity.temperature_unit,
temp,
hass.config.units.temperature_unit,
min_temp,
entity.temperature_unit,
max_temp,
entity.temperature_unit,
)
if min_temp is not None and max_temp is not None:
if not min_temp <= check_temp <= max_temp:
raise ServiceValidationError(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is changing our entity model. Before we can review this PR there needs to be approval in a discussion in our architecture repository.

https://github.com/home-assistant/architecture/discussions

https://developers.home-assistant.io/docs/core/entity#changing-the-entity-model

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok understood, Joost told me something similar :)

translation_domain=DOMAIN,
translation_key="temp_out_of_range",
translation_placeholders={
"entity_id": entity.entity_id,
"check_temp": str(check_temp),
"min_temp": str(min_temp),
"max_temp": str(max_temp),
"temperature_unit": entity.temperature_unit,
},
)
kwargs[value] = check_temp
else:
kwargs[value] = temp

Expand Down
3 changes: 3 additions & 0 deletions homeassistant/components/water_heater/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@
},
"operation_list_not_defined": {
"message": "Operation mode {operation_mode} is not valid for {entity_id}. The operation list is not defined."
},
"temp_out_of_range": {
"message": "Temperature {check_temp} {temperature_unit} is not valid for {entity_id}. Value must be between {min_temp} {temperature_unit} and {max_temp} {temperature_unit}."
}
},
"services": {
Expand Down
22 changes: 11 additions & 11 deletions tests/components/mqtt/test_water_heater.py
Original file line number Diff line number Diff line change
Expand Up @@ -651,14 +651,14 @@ async def test_set_and_templates(

# Temperature
await common.async_set_temperature(
hass, temperature=107, entity_id=ENTITY_WATER_HEATER
hass, temperature=50, entity_id=ENTITY_WATER_HEATER
)
mqtt_mock.async_publish.assert_called_once_with(
"temperature-topic", "temp: 107.0", 0, False, message_expiry_interval=None
"temperature-topic", "temp: 50.0", 0, False, message_expiry_interval=None
)
mqtt_mock.async_publish.reset_mock()
state = hass.states.get(ENTITY_WATER_HEATER)
assert state.attributes.get("temperature") == 107
assert state.attributes.get("temperature") == 50

# Power
await common.async_turn_on(hass, entity_id=ENTITY_WATER_HEATER)
Expand Down Expand Up @@ -1065,10 +1065,10 @@ async def test_precision_default(
mqtt_mock = await mqtt_mock_entry()

await common.async_set_temperature(
hass, temperature=23.67, entity_id=ENTITY_WATER_HEATER
hass, temperature=43.67, entity_id=ENTITY_WATER_HEATER
)
state = hass.states.get(ENTITY_WATER_HEATER)
assert state.attributes.get("temperature") == 23.7
assert state.attributes.get("temperature") == 43.7
mqtt_mock.async_publish.reset_mock()


Expand All @@ -1083,10 +1083,10 @@ async def test_precision_halves(
mqtt_mock = await mqtt_mock_entry()

await common.async_set_temperature(
hass, temperature=23.67, entity_id=ENTITY_WATER_HEATER
hass, temperature=43.67, entity_id=ENTITY_WATER_HEATER
)
state = hass.states.get(ENTITY_WATER_HEATER)
assert state.attributes.get("temperature") == 23.5
assert state.attributes.get("temperature") == 43.5
mqtt_mock.async_publish.reset_mock()


Expand All @@ -1101,10 +1101,10 @@ async def test_precision_whole(
mqtt_mock = await mqtt_mock_entry()

await common.async_set_temperature(
hass, temperature=23.67, entity_id=ENTITY_WATER_HEATER
hass, temperature=43.67, entity_id=ENTITY_WATER_HEATER
)
state = hass.states.get(ENTITY_WATER_HEATER)
assert state.attributes.get("temperature") == 24.0
assert state.attributes.get("temperature") == 44.0
mqtt_mock.async_publish.reset_mock()


Expand All @@ -1121,8 +1121,8 @@ async def test_precision_whole(
(
water_heater.SERVICE_SET_TEMPERATURE,
"temperature_command_topic",
{"temperature": "20.1"},
20.1,
{"temperature": "50.1"},
50.1,
"temperature_command_template",
),
(
Expand Down
183 changes: 149 additions & 34 deletions tests/components/water_heater/test_init.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""The tests for the water heater component."""

from typing import Any
from unittest import mock
from unittest.mock import AsyncMock, MagicMock

Expand Down Expand Up @@ -30,6 +31,45 @@
)


async def async_setup_water_heater_entity(
hass: HomeAssistant, water_heater_entity: WaterHeaterEntity
) -> None:
"""Set up a mock water heater entity with test integration and platform."""

async def async_setup_entry_init(
hass: HomeAssistant, config_entry: ConfigEntry
) -> bool:
await hass.config_entries.async_forward_entry_setups(
config_entry, [Platform.WATER_HEATER]
)
return True

async def async_setup_entry_water_heater_platform(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
async_add_entities([water_heater_entity])

mock_integration(
hass,
MockModule(
"test",
async_setup_entry=async_setup_entry_init,
),
built_in=False,
)
mock_platform(
hass,
"test.water_heater",
MockPlatform(async_setup_entry=async_setup_entry_water_heater_platform),
)

config_entry = MockConfigEntry(domain="test")
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)


async def test_set_temp_schema_no_req(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
Expand Down Expand Up @@ -68,6 +108,114 @@ async def test_set_temp_schema(
assert calls[-1].data == data


@pytest.mark.parametrize(
(
"temperature_unit",
"input_temperature",
"expected_check_temp",
),
[
(UnitOfTemperature.CELSIUS, 20.0, "20.0"),
(UnitOfTemperature.FAHRENHEIT, 40.0, "104.0"),
(UnitOfTemperature.FAHRENHEIT, 61.0, "141.8"),
],
ids=[
"matching_units",
"conversion_below_min",
"conversion_above_max",
],
)
async def test_set_temperature_raises_out_of_range(
hass: HomeAssistant,
config_flow_fixture: None,
temperature_unit: str,
input_temperature: float,
expected_check_temp: str,
) -> None:
"""Test setting temperature outside of range raises validation error."""
water_heater_entity = MockWaterHeaterEntity()
water_heater_entity.hass = hass
water_heater_entity._attr_name = "test"
water_heater_entity._attr_unique_id = "test"
water_heater_entity._attr_supported_features = (
WaterHeaterEntityFeature.TARGET_TEMPERATURE
)
water_heater_entity._attr_temperature_unit = temperature_unit

await async_setup_water_heater_entity(hass, water_heater_entity)

data = {"entity_id": "water_heater.test", "temperature": input_temperature}

with pytest.raises(ServiceValidationError) as exc:
await hass.services.async_call(
DOMAIN,
"set_temperature",
data,
blocking=True,
)

assert exc.value.translation_domain == DOMAIN
assert exc.value.translation_key == "temp_out_of_range"
assert exc.value.translation_placeholders == {
"entity_id": "water_heater.test",
"check_temp": expected_check_temp,
"min_temp": str(water_heater_entity.min_temp),
"max_temp": str(water_heater_entity.max_temp),
"temperature_unit": water_heater_entity.temperature_unit,
}


@pytest.mark.parametrize(
("input_temperature", "expected_check_temp"),
[
(43.3, 110.0),
(60.0, 140.0),
],
ids=["displayed_min", "displayed_max"],
)
async def test_set_temperature_accepts_displayed_boundary_value(
hass: HomeAssistant,
config_flow_fixture: None,
input_temperature: float,
expected_check_temp: float,
) -> None:
"""Test temperature at the displayed boundary values is accepted."""

class BoundaryWaterHeater(MockWaterHeaterEntity):
def __init__(self) -> None:
super().__init__()
self.last_set_temperature: dict[str, Any] | None = None

async def async_set_temperature(self, **kwargs: Any) -> None:
self.last_set_temperature = kwargs

water_heater_entity = BoundaryWaterHeater()
water_heater_entity.hass = hass
water_heater_entity._attr_name = "test"
water_heater_entity._attr_unique_id = "test"
water_heater_entity._attr_supported_features = (
WaterHeaterEntityFeature.TARGET_TEMPERATURE
)
water_heater_entity._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT

await async_setup_water_heater_entity(hass, water_heater_entity)

data = {"entity_id": "water_heater.test", "temperature": input_temperature}

await hass.services.async_call(
DOMAIN,
"set_temperature",
data,
blocking=True,
)
await hass.async_block_till_done()

assert water_heater_entity.last_set_temperature == {
"entity_id": ["water_heater.test"],
"temperature": expected_check_temp,
}


class MockWaterHeaterEntity(WaterHeaterEntity):
"""Mock water heater device to use in tests."""

Expand Down Expand Up @@ -129,40 +277,7 @@ async def test_operation_mode_validation(
water_heater_entity._attr_current_operation = None
water_heater_entity._attr_operation_list = None

async def async_setup_entry_init(
hass: HomeAssistant, config_entry: ConfigEntry
) -> bool:
"""Set up test config entry."""
await hass.config_entries.async_forward_entry_setups(
config_entry, [Platform.WATER_HEATER]
)
return True

async def async_setup_entry_water_heater_platform(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up test water_heater platform via config entry."""
async_add_entities([water_heater_entity])

mock_integration(
hass,
MockModule(
"test",
async_setup_entry=async_setup_entry_init,
),
built_in=False,
)
mock_platform(
hass,
"test.water_heater",
MockPlatform(async_setup_entry=async_setup_entry_water_heater_platform),
)

config_entry = MockConfigEntry(domain="test")
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await async_setup_water_heater_entity(hass, water_heater_entity)

data = {"entity_id": "water_heater.test", "operation_mode": "test"}

Expand Down
Loading