1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-08 17:49:37 +01:00
Files
core/homeassistant/components/generic_thermostat/climate.py
T

719 lines
27 KiB
Python

"""Adds support for generic thermostat units."""
from __future__ import annotations
import asyncio
from collections.abc import Mapping
from datetime import datetime, timedelta
from functools import partial
import logging
import math
from typing import Any
import voluptuous as vol
from homeassistant.components.climate import (
ATTR_PRESET_MODE,
PLATFORM_SCHEMA as CLIMATE_PLATFORM_SCHEMA,
PRESET_NONE,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_TEMPERATURE,
CONF_NAME,
CONF_UNIQUE_ID,
EVENT_HOMEASSISTANT_START,
PRECISION_HALVES,
PRECISION_TENTHS,
PRECISION_WHOLE,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_ON,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
UnitOfTemperature,
)
from homeassistant.core import (
CALLBACK_TYPE,
DOMAIN as HOMEASSISTANT_DOMAIN,
Context,
CoreState,
Event,
EventStateChangedData,
HomeAssistant,
State,
callback,
)
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,
CONF_PRESETS,
CONF_SENSOR,
DEFAULT_TOLERANCE,
DOMAIN,
PLATFORMS,
)
_LOGGER = logging.getLogger(__name__)
DEFAULT_NAME = "Generic Thermostat"
CONF_INITIAL_HVAC_MODE = "initial_hvac_mode"
CONF_PRECISION = "precision"
CONF_TARGET_TEMP = "target_temp"
CONF_TEMP_STEP = "target_temp_step"
PRESETS_SCHEMA: VolDictType = {
vol.Optional(v): vol.Coerce(float) for v in CONF_PRESETS.values()
}
PLATFORM_SCHEMA_COMMON = vol.Schema(
{
vol.Required(CONF_HEATER): cv.entity_id,
vol.Required(CONF_SENSOR): cv.entity_id,
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),
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_INITIAL_HVAC_MODE): vol.In(
[HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF]
),
vol.Optional(CONF_PRECISION): vol.All(
vol.Coerce(float),
vol.In([PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE]),
),
vol.Optional(CONF_TEMP_STEP): vol.All(
vol.In([PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE])
),
vol.Optional(CONF_UNIQUE_ID): cv.string,
**PRESETS_SCHEMA,
}
)
PLATFORM_SCHEMA = CLIMATE_PLATFORM_SCHEMA.extend(PLATFORM_SCHEMA_COMMON.schema)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize config entry."""
await _async_setup_config(
hass,
PLATFORM_SCHEMA_COMMON(dict(config_entry.options)),
config_entry.entry_id,
async_add_entities,
)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the generic thermostat platform."""
await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
await _async_setup_config(
hass, config, config.get(CONF_UNIQUE_ID), async_add_entities
)
async def _async_setup_config(
hass: HomeAssistant,
config: Mapping[str, Any],
unique_id: str | None,
async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the generic thermostat platform."""
name: str = config[CONF_NAME]
heater_entity_id: str = config[CONF_HEATER]
sensor_entity_id: str = config[CONF_SENSOR]
min_temp: float | None = config.get(CONF_MIN_TEMP)
max_temp: float | None = config.get(CONF_MAX_TEMP)
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)
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
}
precision: float | None = config.get(CONF_PRECISION)
target_temperature_step: float | None = config.get(CONF_TEMP_STEP)
unit = hass.config.units.temperature_unit
async_add_entities(
[
GenericThermostat(
hass,
name=name,
heater_entity_id=heater_entity_id,
sensor_entity_id=sensor_entity_id,
min_temp=min_temp,
max_temp=max_temp,
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,
initial_hvac_mode=initial_hvac_mode,
presets=presets,
precision=precision,
target_temperature_step=target_temperature_step,
unit=unit,
unique_id=unique_id,
)
]
)
class GenericThermostat(ClimateEntity, RestoreEntity):
"""Representation of a Generic Thermostat device."""
_attr_should_poll = False
def __init__(
self,
hass: HomeAssistant,
*,
name: str,
heater_entity_id: str,
sensor_entity_id: str,
min_temp: float | None,
max_temp: float | None,
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,
initial_hvac_mode: HVACMode | None,
presets: dict[str, float],
precision: float | None,
target_temperature_step: float | None,
unit: UnitOfTemperature,
unique_id: str | None,
) -> None:
"""Initialize the thermostat."""
self._attr_name = name
self.heater_entity_id = heater_entity_id
self.sensor_entity_id = sensor_entity_id
self.device_entry = async_entity_id_to_device(
hass,
heater_entity_id,
)
self.ac_mode = ac_mode
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
self._saved_target_temp = target_temp or next(iter(presets.values()), None)
self._temp_precision = precision
self._temp_target_temperature_step = target_temperature_step
if self.ac_mode:
self._attr_hvac_modes = [HVACMode.COOL, HVACMode.OFF]
else:
self._attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF]
self._active = False
self._cur_temp: float | None = None
self._temp_lock = asyncio.Lock()
self._min_temp = min_temp
self._max_temp = max_temp
self._attr_preset_mode = PRESET_NONE
self._target_temp = target_temp
self._attr_temperature_unit = unit
self._attr_unique_id = unique_id
self._attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
if len(presets):
self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE
self._attr_preset_modes = [PRESET_NONE, *presets.keys()]
else:
self._attr_preset_modes = [PRESET_NONE]
self._presets = presets
self._presets_inv = {v: k for k, v in presets.items()}
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added."""
await super().async_added_to_hass()
# Add listener
self.async_on_remove(
async_track_state_change_event(
self.hass, [self.sensor_entity_id], self._async_sensor_changed
)
)
self.async_on_remove(
async_track_state_change_event(
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(
async_track_time_interval(
self.hass, self._async_control_heating, self._keep_alive
)
)
@callback
def _async_startup(_: Event | None = None) -> None:
"""Init on startup."""
sensor_state = self.hass.states.get(self.sensor_entity_id)
if sensor_state and sensor_state.state not in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
self._async_update_temp(sensor_state)
self.async_write_ha_state()
switch_state = self.hass.states.get(self.heater_entity_id)
if switch_state and switch_state.state not in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
self.hass.async_create_task(
self._check_switch_initial_state(), eager_start=True
)
if self.hass.state is CoreState.running:
_async_startup()
else:
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_startup)
# Check If we have an old state
if (old_state := await self.async_get_last_state()) is not None:
# If we have no initial temperature, restore
if self._target_temp is None:
# If we have a previously saved temperature
if old_state.attributes.get(ATTR_TEMPERATURE) is None:
if self.ac_mode:
self._target_temp = self.max_temp
else:
self._target_temp = self.min_temp
_LOGGER.warning(
"Undefined target temperature, falling back to %s",
self._target_temp,
)
else:
self._target_temp = float(old_state.attributes[ATTR_TEMPERATURE])
if (
self.preset_modes
and old_state.attributes.get(ATTR_PRESET_MODE) in self.preset_modes
):
self._attr_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE)
if not self._hvac_mode and old_state.state:
self._hvac_mode = HVACMode(old_state.state)
else:
# No previous state, try and restore defaults
if self._target_temp is None:
if self.ac_mode:
self._target_temp = self.max_temp
else:
self._target_temp = self.min_temp
_LOGGER.warning(
"No previously saved temperature, setting to %s", self._target_temp
)
# Set default state to off
if not self._hvac_mode:
self._hvac_mode = HVACMode.OFF
@property
def precision(self) -> float:
"""Return the precision of the system."""
if self._temp_precision is not None:
return self._temp_precision
return super().precision
@property
def target_temperature_step(self) -> float:
"""Return the supported step of target temperature."""
if self._temp_target_temperature_step is not None:
return self._temp_target_temperature_step
# if a target_temperature_step is not defined, fallback to equal the precision
return self.precision
@property
def current_temperature(self) -> float | None:
"""Return the sensor temperature."""
return self._cur_temp
@property
def hvac_mode(self) -> HVACMode | None:
"""Return current operation."""
return self._hvac_mode
@property
def hvac_action(self) -> HVACAction:
"""Return the current running hvac operation if supported.
Need to be one of CURRENT_HVAC_*.
"""
if self._hvac_mode == HVACMode.OFF:
return HVACAction.OFF
if not self._is_device_active:
return HVACAction.IDLE
if self.ac_mode:
return HVACAction.COOLING
return HVACAction.HEATING
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
return self._target_temp
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set hvac mode."""
if hvac_mode == HVACMode.HEAT:
self._hvac_mode = HVACMode.HEAT
await self._async_control_heating(force=True)
elif hvac_mode == HVACMode.COOL:
self._hvac_mode = HVACMode.COOL
await self._async_control_heating(force=True)
elif hvac_mode == HVACMode.OFF:
self._hvac_mode = HVACMode.OFF
if self._is_device_active:
await self._async_heater_turn_off()
else:
_LOGGER.error("Unrecognized hvac mode: %s", hvac_mode)
return
# Ensure we update the current operation after changing the mode
self.async_write_ha_state()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
return
self._attr_preset_mode = self._presets_inv.get(temperature, PRESET_NONE)
self._target_temp = temperature
await self._async_control_heating(force=True)
self.async_write_ha_state()
@property
def min_temp(self) -> float:
"""Return the minimum temperature."""
if self._min_temp is not None:
return self._min_temp
# get default temp from super class
return super().min_temp
@property
def max_temp(self) -> float:
"""Return the maximum temperature."""
if self._max_temp is not None:
return self._max_temp
# Get default temp from super class
return super().max_temp
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_update_temp(new_state)
await self._async_control_heating()
self.async_write_ha_state()
async def _check_switch_initial_state(self) -> None:
"""Prevent the device from keep running if HVACMode.OFF."""
if self._hvac_mode == HVACMode.OFF and self._is_device_active:
_LOGGER.warning(
(
"The climate mode is OFF, but the switch device is ON. Turning off"
" device %s"
),
self.heater_entity_id,
)
await self._async_heater_turn_off()
@callback
def _async_switch_changed(self, event: Event[EventStateChangedData]) -> None:
"""Handle heater switch state changes."""
new_state = event.data["new_state"]
old_state = event.data["old_state"]
if new_state is None:
return
if old_state is 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
def _async_update_temp(self, state: State) -> None:
"""Update thermostat with latest state from sensor."""
try:
cur_temp = float(state.state)
if not math.isfinite(cur_temp):
raise ValueError(f"Sensor has illegal state {state.state}") # noqa: TRY301
self._cur_temp = cur_temp
except ValueError as ex:
_LOGGER.error("Unable to update from sensor: %s", ex)
async def _async_control_heating(
self, time: datetime | None = None, force: bool = False
) -> None:
"""Check if we need to turn heating on or off."""
async with self._temp_lock:
if not self._active and None not in (
self._cur_temp,
self._target_temp,
):
self._active = True
_LOGGER.debug(
(
"Obtained current and target temperature. "
"Generic thermostat active. %s, %s"
),
self._cur_temp,
self._target_temp,
)
if not self._active or self._hvac_mode == HVACMode.OFF:
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(
"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
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:
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,
)
elif time is not None:
# This is a keep-alive call, so ensure it's on
_LOGGER.debug(
"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._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:
# 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(keepalive=True)
@property
def _is_device_active(self) -> bool | None:
"""If the toggleable device is currently active."""
if not self.hass.states.get(self.heater_entity_id):
return None
return self.hass.states.is_state(self.heater_entity_id, STATE_ON)
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=new_context
)
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, 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=new_context
)
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."""
if preset_mode not in (self.preset_modes or []):
raise ValueError(
f"Got unsupported preset_mode {preset_mode}. Must be one of"
f" {self.preset_modes}"
)
if preset_mode == self._attr_preset_mode:
# I don't think we need to call async_write_ha_state if we didn't change the state
return
if preset_mode == PRESET_NONE:
self._attr_preset_mode = PRESET_NONE
self._target_temp = self._saved_target_temp
await self._async_control_heating(force=True)
else:
if self._attr_preset_mode == PRESET_NONE:
self._saved_target_temp = self._target_temp
self._attr_preset_mode = preset_mode
self._target_temp = self._presets[preset_mode]
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()
@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()