hass-core/homeassistant/components/tado/climate.py
chiefdragon 9672db0354
Add new preset to Tado to enable geofencing mode (#92877)
* Add new preset to Tado to enable geofencing mode
Add new 'auto' preset mode to enable Tado to be set to auto geofencing
mode.  The existing ‘home’ and ‘away’ presets switched Tado into manual
geofencing mode and there was no way to restore it to auto mode.
Note 1: Since preset modes (home, away and auto) apply to the Tado home
holistically, irrespective of the Tado climate entity used to select
the preset, three new sensors have been added to display the state of
the Tado home
Note 2: Auto mode is only supported if the Auto Assist skill is enabled
in the owner's Tado home. Various checks have been added to ensure the
Tado supports auto geofencing and if it is not supported, the preset is
not listed in the preset modes available

* Update codeowners in manifest.json

* Update main codeowners file for Tado component
2023-05-23 19:08:00 +02:00

664 lines
21 KiB
Python

"""Support for Tado thermostats."""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant.components.climate import (
FAN_AUTO,
PRESET_AWAY,
PRESET_HOME,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
CONST_EXCLUSIVE_OVERLAY_GROUP,
CONST_FAN_AUTO,
CONST_FAN_OFF,
CONST_MODE_AUTO,
CONST_MODE_COOL,
CONST_MODE_HEAT,
CONST_MODE_OFF,
CONST_MODE_SMART_SCHEDULE,
CONST_OVERLAY_MANUAL,
CONST_OVERLAY_TADO_DEFAULT,
CONST_OVERLAY_TADO_MODE,
CONST_OVERLAY_TADO_OPTIONS,
CONST_OVERLAY_TIMER,
DATA,
DOMAIN,
HA_TERMINATION_DURATION,
HA_TERMINATION_TYPE,
HA_TO_TADO_FAN_MODE_MAP,
HA_TO_TADO_HVAC_MODE_MAP,
HA_TO_TADO_SWING_MODE_MAP,
ORDERED_KNOWN_TADO_MODES,
PRESET_AUTO,
SIGNAL_TADO_UPDATE_RECEIVED,
SUPPORT_PRESET_AUTO,
SUPPORT_PRESET_MANUAL,
TADO_HVAC_ACTION_TO_HA_HVAC_ACTION,
TADO_MODES_WITH_NO_TEMP_SETTING,
TADO_SWING_OFF,
TADO_SWING_ON,
TADO_TO_HA_FAN_MODE_MAP,
TADO_TO_HA_HVAC_MODE_MAP,
TADO_TO_HA_OFFSET_MAP,
TADO_TO_HA_SWING_MODE_MAP,
TEMP_OFFSET,
TYPE_AIR_CONDITIONING,
TYPE_HEATING,
)
from .entity import TadoZoneEntity
_LOGGER = logging.getLogger(__name__)
SERVICE_CLIMATE_TIMER = "set_climate_timer"
ATTR_TIME_PERIOD = "time_period"
ATTR_REQUESTED_OVERLAY = "requested_overlay"
CLIMATE_TIMER_SCHEMA = {
vol.Required(ATTR_TEMPERATURE): vol.Coerce(float),
vol.Exclusive(ATTR_TIME_PERIOD, CONST_EXCLUSIVE_OVERLAY_GROUP): vol.All(
cv.time_period, cv.positive_timedelta, lambda td: td.total_seconds()
),
vol.Exclusive(ATTR_REQUESTED_OVERLAY, CONST_EXCLUSIVE_OVERLAY_GROUP): vol.In(
CONST_OVERLAY_TADO_OPTIONS
),
}
SERVICE_TEMP_OFFSET = "set_climate_temperature_offset"
ATTR_OFFSET = "offset"
CLIMATE_TEMP_OFFSET_SCHEMA = {
vol.Required(ATTR_OFFSET, default=0): vol.Coerce(float),
}
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the Tado climate platform."""
tado = hass.data[DOMAIN][entry.entry_id][DATA]
entities = await hass.async_add_executor_job(_generate_entities, tado)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_CLIMATE_TIMER,
CLIMATE_TIMER_SCHEMA,
"set_timer",
)
platform.async_register_entity_service(
SERVICE_TEMP_OFFSET,
CLIMATE_TEMP_OFFSET_SCHEMA,
"set_temp_offset",
)
async_add_entities(entities, True)
def _generate_entities(tado):
"""Create all climate entities."""
entities = []
for zone in tado.zones:
if zone["type"] in [TYPE_HEATING, TYPE_AIR_CONDITIONING]:
entity = create_climate_entity(
tado, zone["name"], zone["id"], zone["devices"][0]
)
if entity:
entities.append(entity)
return entities
def create_climate_entity(tado, name: str, zone_id: int, device_info: dict):
"""Create a Tado climate entity."""
capabilities = tado.get_capabilities(zone_id)
_LOGGER.debug("Capabilities for zone %s: %s", zone_id, capabilities)
zone_type = capabilities["type"]
support_flags = (
ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE
)
supported_hvac_modes = [
TADO_TO_HA_HVAC_MODE_MAP[CONST_MODE_OFF],
TADO_TO_HA_HVAC_MODE_MAP[CONST_MODE_SMART_SCHEDULE],
]
supported_fan_modes = None
heat_temperatures = None
cool_temperatures = None
if zone_type == TYPE_AIR_CONDITIONING:
# Heat is preferred as it generally has a lower minimum temperature
for mode in ORDERED_KNOWN_TADO_MODES:
if mode not in capabilities:
continue
supported_hvac_modes.append(TADO_TO_HA_HVAC_MODE_MAP[mode])
if capabilities[mode].get("swings"):
support_flags |= ClimateEntityFeature.SWING_MODE
if not capabilities[mode].get("fanSpeeds"):
continue
support_flags |= ClimateEntityFeature.FAN_MODE
if supported_fan_modes:
continue
supported_fan_modes = [
TADO_TO_HA_FAN_MODE_MAP[speed]
for speed in capabilities[mode]["fanSpeeds"]
]
cool_temperatures = capabilities[CONST_MODE_COOL]["temperatures"]
else:
supported_hvac_modes.append(HVACMode.HEAT)
if CONST_MODE_HEAT in capabilities:
heat_temperatures = capabilities[CONST_MODE_HEAT]["temperatures"]
if heat_temperatures is None and "temperatures" in capabilities:
heat_temperatures = capabilities["temperatures"]
if cool_temperatures is None and heat_temperatures is None:
_LOGGER.debug("Not adding zone %s since it has no temperatures", name)
return None
heat_min_temp = None
heat_max_temp = None
heat_step = None
cool_min_temp = None
cool_max_temp = None
cool_step = None
if heat_temperatures is not None:
heat_min_temp = float(heat_temperatures["celsius"]["min"])
heat_max_temp = float(heat_temperatures["celsius"]["max"])
heat_step = heat_temperatures["celsius"].get("step", PRECISION_TENTHS)
if cool_temperatures is not None:
cool_min_temp = float(cool_temperatures["celsius"]["min"])
cool_max_temp = float(cool_temperatures["celsius"]["max"])
cool_step = cool_temperatures["celsius"].get("step", PRECISION_TENTHS)
entity = TadoClimate(
tado,
name,
zone_id,
zone_type,
heat_min_temp,
heat_max_temp,
heat_step,
cool_min_temp,
cool_max_temp,
cool_step,
supported_hvac_modes,
supported_fan_modes,
support_flags,
device_info,
)
return entity
class TadoClimate(TadoZoneEntity, ClimateEntity):
"""Representation of a Tado climate entity."""
_attr_temperature_unit = UnitOfTemperature.CELSIUS
def __init__(
self,
tado,
zone_name,
zone_id,
zone_type,
heat_min_temp,
heat_max_temp,
heat_step,
cool_min_temp,
cool_max_temp,
cool_step,
supported_hvac_modes,
supported_fan_modes,
support_flags,
device_info,
):
"""Initialize of Tado climate entity."""
self._tado = tado
super().__init__(zone_name, tado.home_id, zone_id)
self.zone_id = zone_id
self.zone_type = zone_type
self._attr_unique_id = f"{zone_type} {zone_id} {tado.home_id}"
self._attr_name = zone_name
self._attr_temperature_unit = UnitOfTemperature.CELSIUS
self._attr_translation_key = DOMAIN
self._device_info = device_info
self._device_id = self._device_info["shortSerialNo"]
self._ac_device = zone_type == TYPE_AIR_CONDITIONING
self._supported_hvac_modes = supported_hvac_modes
self._supported_fan_modes = supported_fan_modes
self._attr_supported_features = support_flags
self._available = False
self._cur_temp = None
self._cur_humidity = None
self._heat_min_temp = heat_min_temp
self._heat_max_temp = heat_max_temp
self._heat_step = heat_step
self._cool_min_temp = cool_min_temp
self._cool_max_temp = cool_max_temp
self._cool_step = cool_step
self._target_temp = None
self._current_tado_fan_speed = CONST_FAN_OFF
self._current_tado_hvac_mode = CONST_MODE_OFF
self._current_tado_hvac_action = HVACAction.OFF
self._current_tado_swing_mode = TADO_SWING_OFF
self._tado_zone_data = None
self._tado_geofence_data = None
self._tado_zone_temp_offset = {}
self._async_update_home_data()
self._async_update_zone_data()
async def async_added_to_hass(self) -> None:
"""Register for sensor updates."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
SIGNAL_TADO_UPDATE_RECEIVED.format(self._tado.home_id, "home", "data"),
self._async_update_home_callback,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
SIGNAL_TADO_UPDATE_RECEIVED.format(
self._tado.home_id, "zone", self.zone_id
),
self._async_update_zone_callback,
)
)
@property
def current_humidity(self):
"""Return the current humidity."""
return self._tado_zone_data.current_humidity
@property
def current_temperature(self):
"""Return the sensor temperature."""
return self._tado_zone_data.current_temp
@property
def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat, cool mode.
Need to be one of HVAC_MODE_*.
"""
return TADO_TO_HA_HVAC_MODE_MAP.get(self._current_tado_hvac_mode, HVACMode.OFF)
@property
def hvac_modes(self) -> list[HVACMode]:
"""Return the list of available hvac operation modes.
Need to be a subset of HVAC_MODES.
"""
return self._supported_hvac_modes
@property
def hvac_action(self) -> HVACAction:
"""Return the current running hvac operation if supported.
Need to be one of CURRENT_HVAC_*.
"""
return TADO_HVAC_ACTION_TO_HA_HVAC_ACTION.get(
self._tado_zone_data.current_hvac_action, HVACAction.OFF
)
@property
def fan_mode(self):
"""Return the fan setting."""
if self._ac_device:
return TADO_TO_HA_FAN_MODE_MAP.get(self._current_tado_fan_speed, FAN_AUTO)
return None
@property
def fan_modes(self):
"""List of available fan modes."""
return self._supported_fan_modes
def set_fan_mode(self, fan_mode: str) -> None:
"""Turn fan on/off."""
self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP[fan_mode])
@property
def preset_mode(self):
"""Return the current preset mode (home, away or auto)."""
if "presenceLocked" in self._tado_geofence_data:
if not self._tado_geofence_data["presenceLocked"]:
return PRESET_AUTO
if self._tado_zone_data.is_away:
return PRESET_AWAY
return PRESET_HOME
@property
def preset_modes(self):
"""Return a list of available preset modes."""
if self._tado.get_auto_geofencing_supported():
return SUPPORT_PRESET_AUTO
return SUPPORT_PRESET_MANUAL
def set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
self._tado.set_presence(preset_mode)
@property
def target_temperature_step(self):
"""Return the supported step of target temperature."""
if self._tado_zone_data.current_hvac_mode == CONST_MODE_COOL:
return self._cool_step or self._heat_step
return self._heat_step or self._cool_step
@property
def target_temperature(self):
"""Return the temperature we try to reach."""
# If the target temperature will be None
# if the device is performing an action
# that does not affect the temperature or
# the device is switching states
return self._tado_zone_data.target_temp or self._tado_zone_data.current_temp
def set_timer(self, temperature=None, time_period=None, requested_overlay=None):
"""Set the timer on the entity, and temperature if supported."""
self._control_hvac(
hvac_mode=CONST_MODE_HEAT,
target_temp=temperature,
duration=time_period,
overlay_mode=requested_overlay,
)
def set_temp_offset(self, offset):
"""Set offset on the entity."""
_LOGGER.debug(
"Setting temperature offset for device %s setting to (%d)",
self._device_id,
offset,
)
self._tado.set_temperature_offset(self._device_id, offset)
def set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
return
if self._current_tado_hvac_mode not in (
CONST_MODE_OFF,
CONST_MODE_AUTO,
CONST_MODE_SMART_SCHEDULE,
):
self._control_hvac(target_temp=temperature)
return
new_hvac_mode = CONST_MODE_COOL if self._ac_device else CONST_MODE_HEAT
self._control_hvac(target_temp=temperature, hvac_mode=new_hvac_mode)
def set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
self._control_hvac(hvac_mode=HA_TO_TADO_HVAC_MODE_MAP[hvac_mode])
@property
def available(self) -> bool:
"""Return if the device is available."""
return self._tado_zone_data.available
@property
def min_temp(self):
"""Return the minimum temperature."""
if (
self._current_tado_hvac_mode == CONST_MODE_COOL
and self._cool_min_temp is not None
):
return self._cool_min_temp
if self._heat_min_temp is not None:
return self._heat_min_temp
return self._cool_min_temp
@property
def max_temp(self):
"""Return the maximum temperature."""
if (
self._current_tado_hvac_mode == CONST_MODE_HEAT
and self._heat_max_temp is not None
):
return self._heat_max_temp
if self._heat_max_temp is not None:
return self._heat_max_temp
return self._heat_max_temp
@property
def swing_mode(self):
"""Active swing mode for the device."""
return TADO_TO_HA_SWING_MODE_MAP[self._current_tado_swing_mode]
@property
def swing_modes(self):
"""Swing modes for the device."""
if self.supported_features & ClimateEntityFeature.SWING_MODE:
return [
TADO_TO_HA_SWING_MODE_MAP[TADO_SWING_ON],
TADO_TO_HA_SWING_MODE_MAP[TADO_SWING_OFF],
]
return None
@property
def extra_state_attributes(self):
"""Return temperature offset."""
state_attr = self._tado_zone_temp_offset
state_attr[
HA_TERMINATION_TYPE
] = self._tado_zone_data.default_overlay_termination_type
state_attr[
HA_TERMINATION_DURATION
] = self._tado_zone_data.default_overlay_termination_duration
return state_attr
def set_swing_mode(self, swing_mode: str) -> None:
"""Set swing modes for the device."""
self._control_hvac(swing_mode=HA_TO_TADO_SWING_MODE_MAP[swing_mode])
@callback
def _async_update_zone_data(self):
"""Load tado data into zone."""
self._tado_zone_data = self._tado.data["zone"][self.zone_id]
# Assign offset values to mapped attributes
for offset_key, attr in TADO_TO_HA_OFFSET_MAP.items():
if (
self._device_id in self._tado.data["device"]
and offset_key
in self._tado.data["device"][self._device_id][TEMP_OFFSET]
):
self._tado_zone_temp_offset[attr] = self._tado.data["device"][
self._device_id
][TEMP_OFFSET][offset_key]
self._current_tado_fan_speed = self._tado_zone_data.current_fan_speed
self._current_tado_hvac_mode = self._tado_zone_data.current_hvac_mode
self._current_tado_hvac_action = self._tado_zone_data.current_hvac_action
self._current_tado_swing_mode = self._tado_zone_data.current_swing_mode
@callback
def _async_update_zone_callback(self):
"""Load tado data and update state."""
self._async_update_zone_data()
self.async_write_ha_state()
@callback
def _async_update_home_data(self):
"""Load tado geofencing data into zone."""
self._tado_geofence_data = self._tado.data["geofence"]
@callback
def _async_update_home_callback(self):
"""Load tado data and update state."""
self._async_update_home_data()
self.async_write_ha_state()
def _normalize_target_temp_for_hvac_mode(self):
# Set a target temperature if we don't have any
# This can happen when we switch from Off to On
if self._target_temp is None:
self._target_temp = self._tado_zone_data.current_temp
elif self._current_tado_hvac_mode == CONST_MODE_COOL:
if self._target_temp > self._cool_max_temp:
self._target_temp = self._cool_max_temp
elif self._target_temp < self._cool_min_temp:
self._target_temp = self._cool_min_temp
elif self._current_tado_hvac_mode == CONST_MODE_HEAT:
if self._target_temp > self._heat_max_temp:
self._target_temp = self._heat_max_temp
elif self._target_temp < self._heat_min_temp:
self._target_temp = self._heat_min_temp
def _control_hvac(
self,
hvac_mode=None,
target_temp=None,
fan_mode=None,
swing_mode=None,
duration=None,
overlay_mode=None,
):
"""Send new target temperature to Tado."""
if hvac_mode:
self._current_tado_hvac_mode = hvac_mode
if target_temp:
self._target_temp = target_temp
if fan_mode:
self._current_tado_fan_speed = fan_mode
if swing_mode:
self._current_tado_swing_mode = swing_mode
self._normalize_target_temp_for_hvac_mode()
# tado does not permit setting the fan speed to
# off, you must turn off the device
if (
self._current_tado_fan_speed == CONST_FAN_OFF
and self._current_tado_hvac_mode != CONST_MODE_OFF
):
self._current_tado_fan_speed = CONST_FAN_AUTO
if self._current_tado_hvac_mode == CONST_MODE_OFF:
_LOGGER.debug(
"Switching to OFF for zone %s (%d)", self.zone_name, self.zone_id
)
self._tado.set_zone_off(self.zone_id, CONST_OVERLAY_MANUAL, self.zone_type)
return
if self._current_tado_hvac_mode == CONST_MODE_SMART_SCHEDULE:
_LOGGER.debug(
"Switching to SMART_SCHEDULE for zone %s (%d)",
self.zone_name,
self.zone_id,
)
self._tado.reset_zone_overlay(self.zone_id)
return
# If user gave duration then overlay mode needs to be timer
if duration:
overlay_mode = CONST_OVERLAY_TIMER
# If no duration or timer set to fallback setting
if overlay_mode is None:
overlay_mode = (
self._tado.fallback
if self._tado.fallback is not None
else CONST_OVERLAY_TADO_MODE
)
# If default is Tado default then look it up
if overlay_mode == CONST_OVERLAY_TADO_DEFAULT:
overlay_mode = (
self._tado_zone_data.default_overlay_termination_type
if self._tado_zone_data.default_overlay_termination_type is not None
else CONST_OVERLAY_TADO_MODE
)
# If we ended up with a timer but no duration, set a default duration
if overlay_mode == CONST_OVERLAY_TIMER and duration is None:
duration = (
self._tado_zone_data.default_overlay_termination_duration
if self._tado_zone_data.default_overlay_termination_duration is not None
else "3600"
)
_LOGGER.debug(
(
"Switching to %s for zone %s (%d) with temperature %s °C and duration"
" %s using overlay %s"
),
self._current_tado_hvac_mode,
self.zone_name,
self.zone_id,
self._target_temp,
duration,
overlay_mode,
)
temperature_to_send = self._target_temp
if self._current_tado_hvac_mode in TADO_MODES_WITH_NO_TEMP_SETTING:
# A temperature cannot be passed with these modes
temperature_to_send = None
fan_speed = None
if self.supported_features & ClimateEntityFeature.FAN_MODE:
fan_speed = self._current_tado_fan_speed
swing = None
if self.supported_features & ClimateEntityFeature.SWING_MODE:
swing = self._current_tado_swing_mode
self._tado.set_zone_overlay(
zone_id=self.zone_id,
overlay_mode=overlay_mode, # What to do when the period ends
temperature=temperature_to_send,
duration=duration,
device_type=self.zone_type,
mode=self._current_tado_hvac_mode,
fan_speed=fan_speed, # api defaults to not sending fanSpeed if None specified
swing=swing, # api defaults to not sending swing if None specified
)