diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index d1671fceecae9a..ae57307aec3ab7 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -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( + 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 diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index 8e28b85c138ab2..3050a22357469f 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -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": { diff --git a/tests/components/mqtt/test_water_heater.py b/tests/components/mqtt/test_water_heater.py index 4b72fab68b4b7d..c7ef8bbd8b061c 100644 --- a/tests/components/mqtt/test_water_heater.py +++ b/tests/components/mqtt/test_water_heater.py @@ -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) @@ -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() @@ -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() @@ -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() @@ -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", ), ( diff --git a/tests/components/water_heater/test_init.py b/tests/components/water_heater/test_init.py index 33870bf14d9c2e..4ecdd34905a8a4 100644 --- a/tests/components/water_heater/test_init.py +++ b/tests/components/water_heater/test_init.py @@ -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 @@ -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: @@ -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.""" @@ -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"}