Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 42 additions & 7 deletions homeassistant/components/generic_thermostat/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,14 @@
CONF_MIN_TEMP,
CONF_PRESETS,
CONF_SENSOR,
CONF_SENSOR_ERROR_ACTION,
DEFAULT_SENSOR_ERROR_ACTION,
DEFAULT_TOLERANCE,
DOMAIN,
PLATFORMS,
SENSOR_ERROR_ACTION_FORCE_OFF,
SENSOR_ERROR_ACTION_FORCE_ON,
SENSOR_ERROR_ACTIONS,
)

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -113,6 +118,9 @@
vol.Optional(CONF_HOT_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float),
vol.Optional(CONF_TARGET_TEMP): vol.Coerce(float),
vol.Optional(CONF_KEEP_ALIVE): cv.positive_time_period,
vol.Optional(
CONF_SENSOR_ERROR_ACTION, default=DEFAULT_SENSOR_ERROR_ACTION
): vol.In(SENSOR_ERROR_ACTIONS),
vol.Optional(CONF_INITIAL_HVAC_MODE): vol.In(
[HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF]
),
Expand Down Expand Up @@ -181,6 +189,7 @@ async def _async_setup_config(
cold_tolerance: float = config[CONF_COLD_TOLERANCE]
hot_tolerance: float = config[CONF_HOT_TOLERANCE]
keep_alive: timedelta | None = config.get(CONF_KEEP_ALIVE)
sensor_error_action: str = config[CONF_SENSOR_ERROR_ACTION]
initial_hvac_mode: HVACMode | None = config.get(CONF_INITIAL_HVAC_MODE)
presets: dict[str, float] = {
key: config[value] for key, value in CONF_PRESETS.items() if value in config
Expand All @@ -206,6 +215,7 @@ async def _async_setup_config(
cold_tolerance=cold_tolerance,
hot_tolerance=hot_tolerance,
keep_alive=keep_alive,
sensor_error_action=sensor_error_action,
initial_hvac_mode=initial_hvac_mode,
presets=presets,
precision=precision,
Expand Down Expand Up @@ -239,6 +249,7 @@ def __init__(
cold_tolerance: float,
hot_tolerance: float,
keep_alive: timedelta | None,
sensor_error_action: str,
initial_hvac_mode: HVACMode | None,
presets: dict[str, float],
precision: float | None,
Expand Down Expand Up @@ -267,6 +278,7 @@ def __init__(
self._last_context_id: str | None = None
self._hot_tolerance = hot_tolerance
self._keep_alive = keep_alive
self._sensor_error_action = sensor_error_action
self._hvac_mode = initial_hvac_mode
self._saved_target_temp = target_temp or next(iter(presets.values()), None)
self._temp_precision = precision
Expand Down Expand Up @@ -428,6 +440,14 @@ def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
return self._target_temp

@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return optional state attributes."""
return {
"sensor_error": self._cur_temp is None,
"sensor_error_action": self._sensor_error_action,
}

async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set hvac mode."""
if hvac_mode == HVACMode.HEAT:
Expand Down Expand Up @@ -479,9 +499,6 @@ def max_temp(self) -> float:
async def _async_sensor_changed(self, event: Event[EventStateChangedData]) -> None:
"""Handle temperature changes."""
new_state = event.data["new_state"]
if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return

self.async_set_context(event.context)
self._async_update_temp(new_state)
await self._async_control_heating()
Expand Down Expand Up @@ -525,11 +542,16 @@ def _async_switch_changed(self, event: Event[EventStateChangedData]) -> None:
self.async_write_ha_state()

@callback
def _async_update_temp(self, state: State) -> None:
def _async_update_temp(self, state: State | None) -> None:
"""Update thermostat with latest state from sensor."""
# Propagating sensor availability to current temperature attribute
if state is None or state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
self._cur_temp = None
return
try:
cur_temp = float(state.state)
if not math.isfinite(cur_temp):
self._cur_temp = None
raise ValueError(f"Sensor has illegal state {state.state}") # noqa: TRY301
self._cur_temp = cur_temp
except ValueError as ex:
Expand Down Expand Up @@ -570,9 +592,12 @@ async def _async_control_heating(
await self._async_heater_turn_off()
return

assert self._cur_temp is not None and self._target_temp is not None
too_cold = self._target_temp > self._cur_temp + self._cold_tolerance
too_hot = self._target_temp < self._cur_temp - self._hot_tolerance
assert self._target_temp is not None
if self._cur_temp is None:
too_cold, too_hot = self._sensor_error_tolerances()
else:
too_cold = self._target_temp > self._cur_temp + self._cold_tolerance
too_hot = self._target_temp < self._cur_temp - self._hot_tolerance
now = dt_util.utcnow()

if self._is_device_active:
Expand Down Expand Up @@ -623,6 +648,16 @@ async def _async_control_heating(
)
await self._async_heater_turn_off(keepalive=True)

def _sensor_error_tolerances(self) -> tuple[bool, bool]:
"""Compute control tolerances when sensor temperature is unavailable."""
if self._sensor_error_action == SENSOR_ERROR_ACTION_FORCE_OFF:
return (False, True) if not self.ac_mode else (True, False)

if self._sensor_error_action == SENSOR_ERROR_ACTION_FORCE_ON:
return (True, False) if not self.ac_mode else (False, True)

return False, False

@property
def _is_device_active(self) -> bool | None:
"""If the toggleable device is currently active."""
Expand Down
11 changes: 11 additions & 0 deletions homeassistant/components/generic_thermostat/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,11 @@
CONF_MIN_TEMP,
CONF_PRESETS,
CONF_SENSOR,
CONF_SENSOR_ERROR_ACTION,
DEFAULT_SENSOR_ERROR_ACTION,
DEFAULT_TOLERANCE,
DOMAIN,
SENSOR_ERROR_ACTIONS,
)

OPTIONS_SCHEMA = {
Expand Down Expand Up @@ -66,6 +69,14 @@
vol.Optional(CONF_KEEP_ALIVE): selector.DurationSelector(
selector.DurationSelectorConfig(allow_negative=False)
),
vol.Optional(
CONF_SENSOR_ERROR_ACTION, default=DEFAULT_SENSOR_ERROR_ACTION
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=SENSOR_ERROR_ACTIONS,
translation_key=CONF_SENSOR_ERROR_ACTION,
)
),
vol.Optional(CONF_MAX_DUR): selector.DurationSelector(
selector.DurationSelectorConfig(allow_negative=False)
),
Expand Down
14 changes: 14 additions & 0 deletions homeassistant/components/generic_thermostat/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,18 @@
}
CONF_SENSOR = "target_sensor"
CONF_KEEP_ALIVE = "keep_alive"
CONF_SENSOR_ERROR_ACTION = "sensor_error_action"

SENSOR_ERROR_ACTION_KEEP = "keep"
SENSOR_ERROR_ACTION_FORCE_OFF = "force_off"
SENSOR_ERROR_ACTION_FORCE_ON = "force_on"

SENSOR_ERROR_ACTIONS = [
SENSOR_ERROR_ACTION_KEEP,
SENSOR_ERROR_ACTION_FORCE_OFF,
SENSOR_ERROR_ACTION_FORCE_ON,
]

DEFAULT_SENSOR_ERROR_ACTION = SENSOR_ERROR_ACTION_KEEP

DEFAULT_TOLERANCE = 0.3
13 changes: 13 additions & 0 deletions homeassistant/components/generic_thermostat/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"min_cycle_duration": "Minimum run time",
"min_temp": "Minimum target temperature",
"name": "[%key:common::config_flow::data::name%]",
"sensor_error_action": "Sensor error action",
"target_sensor": "Temperature sensor"
},
"data_description": {
Expand All @@ -39,6 +40,7 @@
"keep_alive": "Trigger the heater periodically to keep devices from losing state.",
"max_cycle_duration": "Once switched on, the maximum amount of time that can elapse before it will be switched off.",
"min_cycle_duration": "Once switched on, the minimum amount of time that must elapse before it may be switched off.",
"sensor_error_action": "Action to apply when the temperature sensor is unavailable or has an invalid value.",
"target_sensor": "Temperature sensor that reflects the current temperature."
},
"description": "Create a climate entity that controls the temperature via a switch and sensor.",
Expand All @@ -63,6 +65,7 @@
"max_temp": "[%key:component::generic_thermostat::config::step::user::data::max_temp%]",
"min_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data::min_cycle_duration%]",
"min_temp": "[%key:component::generic_thermostat::config::step::user::data::min_temp%]",
"sensor_error_action": "[%key:component::generic_thermostat::config::step::user::data::sensor_error_action%]",
"target_sensor": "[%key:component::generic_thermostat::config::step::user::data::target_sensor%]"
},
"data_description": {
Expand All @@ -74,6 +77,7 @@
"keep_alive": "[%key:component::generic_thermostat::config::step::user::data_description::keep_alive%]",
"max_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data_description::max_cycle_duration%]",
"min_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data_description::min_cycle_duration%]",
"sensor_error_action": "[%key:component::generic_thermostat::config::step::user::data_description::sensor_error_action%]",
"target_sensor": "[%key:component::generic_thermostat::config::step::user::data_description::target_sensor%]"
}
},
Expand All @@ -90,6 +94,15 @@
}
}
},
"selector": {
"sensor_error_action": {
"options": {
"force_off": "Force output off",
"force_on": "Force output on",
"keep": "Keep current behavior"
}
}
},
"services": {
"reload": {
"description": "Reloads generic thermostats from the YAML-configuration.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@
'none',
'away',
]),
'sensor_error': False,
'sensor_error_action': 'keep',
'supported_features': <ClimateEntityFeature: 401>,
'target_temp_step': 0.1,
'temperature': 7,
Expand All @@ -93,6 +95,8 @@
]),
'max_temp': 35,
'min_temp': 7,
'sensor_error': False,
'sensor_error_action': 'keep',
'supported_features': <ClimateEntityFeature: 385>,
'target_temp_step': 0.1,
'temperature': 7.0,
Expand Down
Loading
Loading