Compare commits

...
Sign in to create a new pull request.

10 commits

Author SHA1 Message Date
G Johansson
cba8da13ae Add to demo 2024-08-23 13:10:03 +00:00
G Johansson
95d837e7ac Fixes 2024-08-23 12:24:59 +00:00
G Johansson
5b5f0b5883 Reset temp unit 2024-08-23 12:03:52 +00:00
G Johansson
df18430e43 More fixes 2024-08-23 12:03:52 +00:00
G Johansson
719ccc1395 Fixes 2024-08-23 12:03:52 +00:00
G Johansson
dd177e1a4c min_temperature_range None by default 2024-08-23 12:03:52 +00:00
G Johansson
b1056b60b5 Validate range to max/min 2024-08-23 12:03:52 +00:00
G Johansson
0c1f23ffc8 Change min_temp_range to not set 2024-08-23 12:03:51 +00:00
G Johansson
4d7282fb46 Fixes 2024-08-23 12:03:51 +00:00
G Johansson
11885b69a6 Add minimum setpoint deadband to Climate entity 2024-08-23 12:03:51 +00:00
5 changed files with 266 additions and 0 deletions

View file

@ -67,6 +67,7 @@ from .const import ( # noqa: F401
ATTR_MAX_TEMP, ATTR_MAX_TEMP,
ATTR_MIN_HUMIDITY, ATTR_MIN_HUMIDITY,
ATTR_MIN_TEMP, ATTR_MIN_TEMP,
ATTR_MIN_TEMP_RANGE,
ATTR_PRESET_MODE, ATTR_PRESET_MODE,
ATTR_PRESET_MODES, ATTR_PRESET_MODES,
ATTR_SWING_MODE, ATTR_SWING_MODE,
@ -248,6 +249,7 @@ CACHED_PROPERTIES_WITH_ATTR_ = {
"target_temperature_step", "target_temperature_step",
"target_temperature_high", "target_temperature_high",
"target_temperature_low", "target_temperature_low",
"min_temperature_range",
"preset_mode", "preset_mode",
"preset_modes", "preset_modes",
"is_aux_heat", "is_aux_heat",
@ -302,6 +304,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
_attr_target_humidity: float | None = None _attr_target_humidity: float | None = None
_attr_target_temperature_high: float | None _attr_target_temperature_high: float | None
_attr_target_temperature_low: float | None _attr_target_temperature_low: float | None
_attr_min_temperature_range: float | None
_attr_target_temperature_step: float | None = None _attr_target_temperature_step: float | None = None
_attr_target_temperature: float | None = None _attr_target_temperature: float | None = None
_attr_temperature_unit: str _attr_temperature_unit: str
@ -544,6 +547,37 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
data[ATTR_TARGET_TEMP_LOW] = show_temp( data[ATTR_TARGET_TEMP_LOW] = show_temp(
hass, self.target_temperature_low, temperature_unit, precision hass, self.target_temperature_low, temperature_unit, precision
) )
if (
hasattr(self, "min_temperature_range")
and (min_temp_range := self.min_temperature_range) is not None
):
data[ATTR_MIN_TEMP_RANGE] = min_temp_range
if (
self.hass.config.units.temperature_unit != temperature_unit
and temperature_unit == UnitOfTemperature.CELSIUS
and (
show_temp_range := show_temp(
hass,
min_temp_range,
temperature_unit,
precision,
)
)
):
data[ATTR_MIN_TEMP_RANGE] = show_temp_range - 32
elif (
self.hass.config.units.temperature_unit != temperature_unit
and temperature_unit == UnitOfTemperature.FAHRENHEIT
and (
show_temp_range := show_temp(
hass,
min_temp_range,
temperature_unit,
precision,
)
)
):
data[ATTR_MIN_TEMP_RANGE] = show_temp_range + 32
if (current_humidity := self.current_humidity) is not None: if (current_humidity := self.current_humidity) is not None:
data[ATTR_CURRENT_HUMIDITY] = current_humidity data[ATTR_CURRENT_HUMIDITY] = current_humidity
@ -634,6 +668,14 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
""" """
return self._attr_target_temperature_low return self._attr_target_temperature_low
@cached_property
def min_temperature_range(self) -> float | None:
"""Return the minimum setpoint deadband when using a temperature range.
Requires ClimateEntityFeature.TARGET_TEMPERATURE_RANGE.
"""
return self._attr_min_temperature_range
@cached_property @cached_property
def preset_mode(self) -> str | None: def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp. """Return the current preset mode, e.g., home, away, temp.
@ -948,6 +990,51 @@ async def async_service_temperature_set(
else: else:
kwargs[value] = temp kwargs[value] = temp
if not hasattr(entity, "min_temperature_range"):
await entity.async_set_temperature(**kwargs)
return
if (
(min_temp_range := entity.min_temperature_range)
and (target_low_temp := kwargs.get(ATTR_TARGET_TEMP_LOW))
and (target_high_temp := kwargs.get(ATTR_TARGET_TEMP_HIGH))
and (target_high_temp - target_low_temp) < min_temp_range
):
# Ensure target_low_temp is not higher than target_high_temp.
if target_low_temp > target_high_temp:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="low_temp_higher_than_high_temp",
)
# Ensure deadband between target temperatures.
initial_low_temp = target_low_temp
initial_high_temp = target_high_temp
if (target_high_temp - target_low_temp) < min_temp_range:
target_low_temp = target_high_temp - min_temp_range
# Increasing the target temperatures if needed when
# target_low_temp is below min_temp
if target_low_temp < min_temp:
target_low_temp = min_temp
target_high_temp = target_low_temp + min_temp_range
_LOGGER.debug(
"Moved low temperature from %d %s to %d %s and high temperature %d %s to %d %s"
" due to deadband set to %d",
initial_low_temp,
temp_unit,
target_low_temp,
temp_unit,
initial_high_temp,
temp_unit,
target_high_temp,
temp_unit,
min_temp_range,
)
kwargs[ATTR_TARGET_TEMP_HIGH] = target_high_temp
kwargs[ATTR_TARGET_TEMP_LOW] = target_low_temp
await entity.async_set_temperature(**kwargs) await entity.async_set_temperature(**kwargs)

View file

@ -137,6 +137,7 @@ ATTR_SWING_MODE = "swing_mode"
ATTR_TARGET_TEMP_HIGH = "target_temp_high" ATTR_TARGET_TEMP_HIGH = "target_temp_high"
ATTR_TARGET_TEMP_LOW = "target_temp_low" ATTR_TARGET_TEMP_LOW = "target_temp_low"
ATTR_TARGET_TEMP_STEP = "target_temp_step" ATTR_TARGET_TEMP_STEP = "target_temp_step"
ATTR_MIN_TEMP_RANGE = "min_temperature_range"
DEFAULT_MIN_TEMP = 7 DEFAULT_MIN_TEMP = 7
DEFAULT_MAX_TEMP = 35 DEFAULT_MAX_TEMP = 35

View file

@ -269,6 +269,9 @@
}, },
"temp_out_of_range": { "temp_out_of_range": {
"message": "Provided temperature {check_temp} is not valid. Accepted range is {min_temp} to {max_temp}." "message": "Provided temperature {check_temp} is not valid. Accepted range is {min_temp} to {max_temp}."
},
"low_temp_higher_than_high_temp": {
"message": "Target low temperature can not be higher than target high temperature."
} }
} }
} }

View file

@ -133,6 +133,7 @@ class DemoClimate(ClimateEntity):
self._attr_supported_features |= ( self._attr_supported_features |= (
ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
) )
self._attr_min_temperature_range = 3
self._attr_supported_features |= ( self._attr_supported_features |= (
ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
) )

View file

@ -22,6 +22,7 @@ from homeassistant.components.climate.const import (
ATTR_FAN_MODE, ATTR_FAN_MODE,
ATTR_MAX_TEMP, ATTR_MAX_TEMP,
ATTR_MIN_TEMP, ATTR_MIN_TEMP,
ATTR_MIN_TEMP_RANGE,
ATTR_PRESET_MODE, ATTR_PRESET_MODE,
ATTR_SWING_MODE, ATTR_SWING_MODE,
ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_HIGH,
@ -1050,3 +1051,176 @@ async def test_temperature_validation(
state = hass.states.get("climate.test") state = hass.states.get("climate.test")
assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == 10 assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == 10
assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 25 assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 25
async def test_temperature_range_deadband(
hass: HomeAssistant,
register_test_integration: MockConfigEntry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test temperature range with deadband."""
class MockClimateEntityTargetTemp(MockClimateEntity):
"""Mock climate class with mocked aux heater."""
_attr_supported_features = (
ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.SWING_MODE
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_min_temperature_range = 3
_attr_target_temperature_high = 16
_attr_target_temperature_low = 11
_attr_max_temp = 20
_attr_min_temp = 10
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
self._attr_target_temperature_high = kwargs.get(ATTR_TARGET_TEMP_HIGH)
self._attr_target_temperature_low = kwargs.get(ATTR_TARGET_TEMP_LOW)
test_climate = MockClimateEntityTargetTemp(
name="Test",
unique_id="unique_climate_test",
)
setup_test_component_platform(
hass, DOMAIN, entities=[test_climate], from_config_entry=True
)
await hass.config_entries.async_setup(register_test_integration.entry_id)
await hass.async_block_till_done()
state = hass.states.get("climate.test")
assert state is not None
await hass.services.async_call(
DOMAIN,
SERVICE_SET_TEMPERATURE,
{
"entity_id": "climate.test",
ATTR_TARGET_TEMP_HIGH: 15,
ATTR_TARGET_TEMP_LOW: 10,
},
blocking=True,
)
state = hass.states.get("climate.test")
assert state.attributes[ATTR_TARGET_TEMP_LOW] == 10.0
assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 15.0
assert state.attributes[ATTR_MIN_TEMP_RANGE] == 3.0
await hass.services.async_call(
DOMAIN,
SERVICE_SET_TEMPERATURE,
{
"entity_id": "climate.test",
ATTR_TARGET_TEMP_HIGH: 20,
ATTR_TARGET_TEMP_LOW: 11,
},
blocking=True,
)
state = hass.states.get("climate.test")
assert state.attributes[ATTR_TARGET_TEMP_LOW] == 11.0
assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 20.0
assert state.attributes[ATTR_MIN_TEMP_RANGE] == 3.0
await hass.services.async_call(
DOMAIN,
SERVICE_SET_TEMPERATURE,
{
"entity_id": "climate.test",
ATTR_TARGET_TEMP_HIGH: 20,
ATTR_TARGET_TEMP_LOW: 18,
},
blocking=True,
)
state = hass.states.get("climate.test")
assert state.attributes[ATTR_TARGET_TEMP_LOW] == 17.0
assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 20.0
assert state.attributes[ATTR_MIN_TEMP_RANGE] == 3.0
assert (
"Moved low temperature from 18 °C to 17 °C and high temperature 20 °C to 20 °C due to deadband set to 3"
in caplog.text
)
# Raises as not allowed for min temp to be higher than high temp
with pytest.raises(ServiceValidationError):
await hass.services.async_call(
DOMAIN,
SERVICE_SET_TEMPERATURE,
{
"entity_id": "climate.test",
ATTR_TARGET_TEMP_HIGH: 18,
ATTR_TARGET_TEMP_LOW: 20,
},
blocking=True,
)
await hass.services.async_call(
DOMAIN,
SERVICE_SET_TEMPERATURE,
{
"entity_id": "climate.test",
ATTR_TARGET_TEMP_HIGH: 12,
ATTR_TARGET_TEMP_LOW: 10,
},
blocking=True,
)
state = hass.states.get("climate.test")
assert state.attributes[ATTR_TARGET_TEMP_LOW] == 10.0
assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 13.0
assert state.attributes[ATTR_MIN_TEMP_RANGE] == 3.0
hass.config.units.temperature_unit = UnitOfTemperature.FAHRENHEIT
# Does not raise as 5 degrees Fahrenheit is within the 3 degrees Celsius range
await hass.services.async_call(
DOMAIN,
SERVICE_SET_TEMPERATURE,
{
"entity_id": "climate.test",
ATTR_TARGET_TEMP_HIGH: 60, # 15.5 C
ATTR_TARGET_TEMP_LOW: 55, # 12.7 C
},
blocking=True,
)
state = hass.states.get("climate.test")
assert state.attributes[ATTR_TARGET_TEMP_LOW] == 55.0
assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 60.0
assert state.attributes[ATTR_MIN_TEMP_RANGE] == 5.0
await hass.services.async_call(
DOMAIN,
SERVICE_SET_TEMPERATURE,
{
"entity_id": "climate.test",
ATTR_TARGET_TEMP_HIGH: 60,
ATTR_TARGET_TEMP_LOW: 56,
},
blocking=True,
)
state = hass.states.get("climate.test")
assert state.attributes[ATTR_TARGET_TEMP_LOW] == 55.0
assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 60.0
assert state.attributes[ATTR_MIN_TEMP_RANGE] == 5.0
await hass.services.async_call(
DOMAIN,
SERVICE_SET_TEMPERATURE,
{
"entity_id": "climate.test",
ATTR_TARGET_TEMP_HIGH: 54,
ATTR_TARGET_TEMP_LOW: 50,
},
blocking=True,
)
state = hass.states.get("climate.test")
assert state.attributes[ATTR_TARGET_TEMP_LOW] == 50.0
assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 55.0
assert state.attributes[ATTR_MIN_TEMP_RANGE] == 5.0
hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS