Skip to content
Merged
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
9 changes: 7 additions & 2 deletions homeassistant/components/generic_thermostat/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
async_remove_helper_config_entry_from_source_device,
)

from .const import CONF_HEATER, CONF_SENSOR, PLATFORMS
from .const import CONF_DUR_COOLDOWN, CONF_HEATER, CONF_MIN_DUR, CONF_SENSOR, PLATFORMS

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -91,8 +91,13 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
helper_config_entry_id=config_entry.entry_id,
source_device_id=source_device_id,
)
if config_entry.minor_version < 3:
# Set `cycle_cooldown` to `min_cycle_duration` to mimic the old behavior
if CONF_MIN_DUR in options:
options[CONF_DUR_COOLDOWN] = options[CONF_MIN_DUR]

hass.config_entries.async_update_entry(
config_entry, options=options, minor_version=2
config_entry, options=options, minor_version=3
)

_LOGGER.debug(
Expand Down
193 changes: 148 additions & 45 deletions homeassistant/components/generic_thermostat/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import asyncio
from collections.abc import Mapping
from datetime import datetime, timedelta
from functools import partial
import logging
import math
from typing import Any
Expand Down Expand Up @@ -38,35 +39,40 @@
UnitOfTemperature,
)
from homeassistant.core import (
CALLBACK_TYPE,
DOMAIN as HOMEASSISTANT_DOMAIN,
Context,
CoreState,
Event,
EventStateChangedData,
HomeAssistant,
State,
callback,
)
from homeassistant.exceptions import ConditionError
from homeassistant.helpers import condition, config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device import async_entity_id_to_device
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.event import (
async_call_later,
async_track_state_change_event,
async_track_time_interval,
)
from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolDictType
from homeassistant.util import dt as dt_util

from .const import (
CONF_AC_MODE,
CONF_COLD_TOLERANCE,
CONF_DUR_COOLDOWN,
CONF_HEATER,
CONF_HOT_TOLERANCE,
CONF_KEEP_ALIVE,
CONF_MAX_DUR,
CONF_MAX_TEMP,
CONF_MIN_DUR,
CONF_MIN_TEMP,
Expand Down Expand Up @@ -98,6 +104,8 @@
vol.Optional(CONF_AC_MODE): cv.boolean,
vol.Optional(CONF_MAX_TEMP): vol.Coerce(float),
vol.Optional(CONF_MIN_DUR): cv.positive_time_period,
vol.Optional(CONF_MAX_DUR): cv.positive_time_period,
vol.Optional(CONF_DUR_COOLDOWN): cv.positive_time_period,
vol.Optional(CONF_MIN_TEMP): vol.Coerce(float),
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_COLD_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float),
Expand Down Expand Up @@ -167,6 +175,8 @@ async def _async_setup_config(
target_temp: float | None = config.get(CONF_TARGET_TEMP)
ac_mode: bool | None = config.get(CONF_AC_MODE)
min_cycle_duration: timedelta | None = config.get(CONF_MIN_DUR)
max_cycle_duration: timedelta | None = config.get(CONF_MAX_DUR)
cycle_cooldown: timedelta | None = config.get(CONF_DUR_COOLDOWN)
cold_tolerance: float = config[CONF_COLD_TOLERANCE]
hot_tolerance: float = config[CONF_HOT_TOLERANCE]
keep_alive: timedelta | None = config.get(CONF_KEEP_ALIVE)
Expand All @@ -190,6 +200,8 @@ async def _async_setup_config(
target_temp=target_temp,
ac_mode=ac_mode,
min_cycle_duration=min_cycle_duration,
max_cycle_duration=max_cycle_duration,
cycle_cooldown=cycle_cooldown,
cold_tolerance=cold_tolerance,
hot_tolerance=hot_tolerance,
keep_alive=keep_alive,
Expand Down Expand Up @@ -221,6 +233,8 @@ def __init__(
target_temp: float | None,
ac_mode: bool | None,
min_cycle_duration: timedelta | None,
max_cycle_duration: timedelta | None,
cycle_cooldown: timedelta | None,
cold_tolerance: float,
hot_tolerance: float,
keep_alive: timedelta | None,
Expand All @@ -240,8 +254,16 @@ def __init__(
heater_entity_id,
)
self.ac_mode = ac_mode
self.min_cycle_duration = min_cycle_duration
self.min_cycle_duration = min_cycle_duration or timedelta()
self.max_cycle_duration = max_cycle_duration
self.cycle_cooldown = cycle_cooldown or timedelta()
self._cold_tolerance = cold_tolerance
# Subtract the cooldown so it doesn't impact startup
self._last_toggled_time = dt_util.utcnow() - self.cycle_cooldown
self._cycle_callback: CALLBACK_TYPE | None = None
self._check_callback: CALLBACK_TYPE | None = None
# Context ID used to detect our own toggles
self._last_context_id: str | None = None
self._hot_tolerance = hot_tolerance
self._keep_alive = keep_alive
self._hvac_mode = initial_hvac_mode
Expand Down Expand Up @@ -289,6 +311,7 @@ async def async_added_to_hass(self) -> None:
self.hass, [self.heater_entity_id], self._async_switch_changed
)
)
self.async_on_remove(self._cancel_timers)

if self._keep_alive:
self.async_on_remove(
Expand Down Expand Up @@ -482,6 +505,18 @@ def _async_switch_changed(self, event: Event[EventStateChangedData]) -> None:
self.hass.async_create_task(
self._check_switch_initial_state(), eager_start=True
)

# Update timestamp on toggle
self._last_toggled_time = new_state.last_changed

# If the user toggles the switch, assume they want control and clear the timers.
# Note: If a manual interaction occurs within the 2s context window of a switch
# toggle initiated by us, we may not detect manual control. Users are advised to
# use the climate entity for reliable control, not the switch entity.
if new_state.context.id != self._last_context_id:
_LOGGER.debug("External switch change detected, clearing timers")
self._last_context_id = None
self._cancel_timers()
self.async_write_ha_state()

@callback
Expand Down Expand Up @@ -517,57 +552,69 @@ async def _async_control_heating(
if not self._active or self._hvac_mode == HVACMode.OFF:
return

# If the `force` argument is True, we
# ignore `min_cycle_duration`.
# If the `time` argument is not none, we were invoked for
# keep-alive purposes, and `min_cycle_duration` is irrelevant.
if not force and time is None and self.min_cycle_duration:
if self._is_device_active:
current_state = STATE_ON
else:
current_state = HVACMode.OFF
try:
long_enough = condition.state(
self.hass,
self.heater_entity_id,
current_state,
self.min_cycle_duration,
)
except ConditionError:
long_enough = False

if not long_enough:
return
if force and time is not None and self.max_cycle_duration:
# We were invoked due to `max_cycle_duration`, so turn off
_LOGGER.debug(
Comment thread
esand marked this conversation as resolved.
"Turning off heater %s due to max cycle time of %s",
self.heater_entity_id,
self.max_cycle_duration,
)
self._cancel_cycle_timer()
await self._async_heater_turn_off()
return

assert self._cur_temp is not None and self._target_temp is not None

min_temp = self._target_temp - self._cold_tolerance
max_temp = self._target_temp + self._hot_tolerance
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()
Comment thread
esand marked this conversation as resolved.

if self._is_device_active:
if (self.ac_mode and self._cur_temp <= min_temp) or (
not self.ac_mode and self._cur_temp >= max_temp
):
_LOGGER.debug("Turning off heater %s", self.heater_entity_id)
await self._async_heater_turn_off()
if (self.ac_mode and too_cold) or (not self.ac_mode and too_hot):
# Make sure it's past the `min_cycle_duration` before turning off
if (
self._last_toggled_time + self.min_cycle_duration <= now
or force
):
_LOGGER.debug("Turning off heater %s", self.heater_entity_id)
await self._async_heater_turn_off()
elif self._check_callback is None:
_LOGGER.debug(
"Minimum cycle time not reached, check again at %s",
self._last_toggled_time + self.min_cycle_duration,
)
self._check_callback = async_call_later(
self.hass,
now - self._last_toggled_time + self.min_cycle_duration,
self._async_timer_control_heating,
)
Comment thread
esand marked this conversation as resolved.
elif time is not None:
# The time argument is passed only in keep-alive case
# This is a keep-alive call, so ensure it's on
_LOGGER.debug(
"Keep-alive - Turning on heater heater %s",
"Keep-alive - Turning on heater %s",
self.heater_entity_id,
)
await self._async_heater_turn_on(keepalive=True)
elif (self.ac_mode and too_hot) or (not self.ac_mode and too_cold):
# Make sure it's past the `cycle_cooldown` before turning on
if self._last_toggled_time + self.cycle_cooldown <= now or force:
_LOGGER.debug("Turning on heater %s", self.heater_entity_id)
await self._async_heater_turn_on()
elif (self.ac_mode and self._cur_temp > max_temp) or (
not self.ac_mode and self._cur_temp < min_temp
):
_LOGGER.debug("Turning on heater %s", self.heater_entity_id)
await self._async_heater_turn_on()
elif self._check_callback is None:
_LOGGER.debug(
"Cooldown time not reached, check again at %s",
self._last_toggled_time + self.cycle_cooldown,
)
self._check_callback = async_call_later(
self.hass,
now - self._last_toggled_time + self.cycle_cooldown,
self._async_timer_control_heating,
)
elif time is not None:
# The time argument is passed only in keep-alive case
# This is a keep-alive call, so ensure it's off
_LOGGER.debug(
"Keep-alive - Turning off heater %s", self.heater_entity_id
)
await self._async_heater_turn_off()
await self._async_heater_turn_off(keepalive=True)

@property
def _is_device_active(self) -> bool | None:
Expand All @@ -577,19 +624,48 @@ def _is_device_active(self) -> bool | None:

return self.hass.states.is_state(self.heater_entity_id, STATE_ON)

async def _async_heater_turn_on(self) -> None:
async def _async_heater_turn_on(self, keepalive: bool = False) -> None:
"""Turn heater toggleable device on."""
data = {ATTR_ENTITY_ID: self.heater_entity_id}
# Create a new context for this service call so we can identify
# the resulting state change event as originating from us
new_context = Context(parent_id=self._context.id if self._context else None)
self.async_set_context(new_context)
self._last_context_id = new_context.id
await self.hass.services.async_call(
HOMEASSISTANT_DOMAIN, SERVICE_TURN_ON, data, context=self._context
HOMEASSISTANT_DOMAIN, SERVICE_TURN_ON, data, context=new_context
Comment thread
emontnemery marked this conversation as resolved.
)
if not keepalive:
# Update timestamp on turn on
self._last_toggled_time = dt_util.utcnow()
self._cancel_check_timer()
if self.max_cycle_duration:
_LOGGER.debug(
"Scheduling maximum run-time shut-off for %s",
self._last_toggled_time + self.max_cycle_duration,
)
self._cancel_cycle_timer()
self._cycle_callback = async_call_later(
self.hass,
self.max_cycle_duration,
partial(self._async_control_heating, force=True),
)

async def _async_heater_turn_off(self) -> None:
async def _async_heater_turn_off(self, keepalive: bool = False) -> None:
"""Turn heater toggleable device off."""
data = {ATTR_ENTITY_ID: self.heater_entity_id}
# Create a new context for this service call so we can identify
# the resulting state change event as originating from us
new_context = Context(parent_id=self._context.id if self._context else None)
self.async_set_context(new_context)
self._last_context_id = new_context.id
await self.hass.services.async_call(
HOMEASSISTANT_DOMAIN, SERVICE_TURN_OFF, data, context=self._context
HOMEASSISTANT_DOMAIN, SERVICE_TURN_OFF, data, context=new_context
Comment thread
esand marked this conversation as resolved.
)
if not keepalive:
# Update timestamp on turn off
self._last_toggled_time = dt_util.utcnow()
self._cancel_timers()

async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
Expand All @@ -613,3 +689,30 @@ async def async_set_preset_mode(self, preset_mode: str) -> None:
await self._async_control_heating(force=True)

self.async_write_ha_state()

async def _async_timer_control_heating(self, _: datetime | None = None) -> None:
"""Reset check timer and control heating."""
self._check_callback = None
await self._async_control_heating()
Comment thread
esand marked this conversation as resolved.

@callback
def _cancel_check_timer(self) -> None:
"""Reset check timer."""
if self._check_callback:
_LOGGER.debug("Cancelling scheduled state check")
self._check_callback()
self._check_callback = None

@callback
def _cancel_cycle_timer(self) -> None:
"""Reset cycle timer."""
if self._cycle_callback:
_LOGGER.debug("Cancelling scheduled shut-off")
self._cycle_callback()
self._cycle_callback = None

@callback
def _cancel_timers(self) -> None:
"""Reset timers."""
self._cancel_check_timer()
self._cancel_cycle_timer()
Loading
Loading