Tado change to async and add Data Update Coordinator (#134175)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Erwin Douna 2025-01-24 13:05:54 +01:00 committed by GitHub
parent 09559a43ad
commit 5d353a9833
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1008 additions and 748 deletions

View File

@ -3,14 +3,15 @@
from datetime import timedelta
import logging
import requests.exceptions
import PyTado
import PyTado.exceptions
from PyTado.interface import Tado
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType
from .const import (
@ -21,11 +22,9 @@ from .const import (
CONST_OVERLAY_TADO_OPTIONS,
DOMAIN,
)
from .coordinator import TadoDataUpdateCoordinator, TadoMobileDeviceUpdateCoordinator
from .models import TadoData
from .services import setup_services
from .tado_connector import TadoConnector
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [
Platform.BINARY_SENSOR,
@ -41,16 +40,17 @@ SCAN_MOBILE_DEVICE_INTERVAL = timedelta(seconds=30)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Tado."""
setup_services(hass)
return True
type TadoConfigEntry = ConfigEntry[TadoConnector]
type TadoConfigEntry = ConfigEntry[TadoData]
async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool:
@ -58,53 +58,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool
_async_import_options_from_data_if_missing(hass, entry)
username = entry.data[CONF_USERNAME]
password = entry.data[CONF_PASSWORD]
fallback = entry.options.get(CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT)
tadoconnector = TadoConnector(hass, username, password, fallback)
_LOGGER.debug("Setting up Tado connection")
try:
await hass.async_add_executor_job(tadoconnector.setup)
except KeyError:
_LOGGER.error("Failed to login to tado")
return False
except RuntimeError as exc:
_LOGGER.error("Failed to setup tado: %s", exc)
return False
except requests.exceptions.Timeout as ex:
raise ConfigEntryNotReady from ex
except requests.exceptions.HTTPError as ex:
if ex.response.status_code > 400 and ex.response.status_code < 500:
_LOGGER.error("Failed to login to tado: %s", ex)
return False
raise ConfigEntryNotReady from ex
# Do first update
await hass.async_add_executor_job(tadoconnector.update)
# Poll for updates in the background
entry.async_on_unload(
async_track_time_interval(
hass,
lambda now: tadoconnector.update(),
SCAN_INTERVAL,
tado = await hass.async_add_executor_job(
Tado,
entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD],
)
except PyTado.exceptions.TadoWrongCredentialsException as err:
raise ConfigEntryError(f"Invalid Tado credentials. Error: {err}") from err
except PyTado.exceptions.TadoException as err:
raise ConfigEntryNotReady(f"Error during Tado setup: {err}") from err
_LOGGER.debug(
"Tado connection established for username: %s", entry.data[CONF_USERNAME]
)
entry.async_on_unload(
async_track_time_interval(
hass,
lambda now: tadoconnector.update_mobile_devices(),
SCAN_MOBILE_DEVICE_INTERVAL,
)
)
coordinator = TadoDataUpdateCoordinator(hass, entry, tado)
await coordinator.async_config_entry_first_refresh()
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
entry.runtime_data = tadoconnector
mobile_coordinator = TadoMobileDeviceUpdateCoordinator(hass, entry, tado)
await mobile_coordinator.async_config_entry_first_refresh()
entry.runtime_data = TadoData(coordinator, mobile_coordinator)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
return True
@ -126,7 +103,7 @@ def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: Confi
hass.config_entries.async_update_entry(entry, options=options)
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
async def update_listener(hass: HomeAssistant, entry: TadoConfigEntry):
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@ -13,21 +13,19 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import TadoConfigEntry
from .const import (
SIGNAL_TADO_UPDATE_RECEIVED,
TYPE_AIR_CONDITIONING,
TYPE_BATTERY,
TYPE_HEATING,
TYPE_HOT_WATER,
TYPE_POWER,
)
from .coordinator import TadoDataUpdateCoordinator
from .entity import TadoDeviceEntity, TadoZoneEntity
from .tado_connector import TadoConnector
_LOGGER = logging.getLogger(__name__)
@ -121,7 +119,7 @@ async def async_setup_entry(
) -> None:
"""Set up the Tado sensor platform."""
tado = entry.runtime_data
tado = entry.runtime_data.coordinator
devices = tado.devices
zones = tado.zones
entities: list[BinarySensorEntity] = []
@ -164,43 +162,23 @@ class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity):
def __init__(
self,
tado: TadoConnector,
coordinator: TadoDataUpdateCoordinator,
device_info: dict[str, Any],
entity_description: TadoBinarySensorEntityDescription,
) -> None:
"""Initialize of the Tado Sensor."""
self.entity_description = entity_description
self._tado = tado
super().__init__(device_info)
super().__init__(device_info, coordinator)
self._attr_unique_id = (
f"{entity_description.key} {self.device_id} {tado.home_id}"
f"{entity_description.key} {self.device_id} {coordinator.home_id}"
)
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, "device", self.device_id
),
self._async_update_callback,
)
)
self._async_update_device_data()
@callback
def _async_update_callback(self) -> None:
"""Update and write state."""
self._async_update_device_data()
self.async_write_ha_state()
@callback
def _async_update_device_data(self) -> None:
"""Handle update callbacks."""
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
try:
self._device_info = self._tado.data["device"][self.device_id]
self._device_info = self.coordinator.data["device"][self.device_id]
except KeyError:
return
@ -209,6 +187,7 @@ class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity):
self._attr_extra_state_attributes = self.entity_description.attributes_fn(
self._device_info
)
super()._handle_coordinator_update()
class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity):
@ -218,42 +197,24 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity):
def __init__(
self,
tado: TadoConnector,
coordinator: TadoDataUpdateCoordinator,
zone_name: str,
zone_id: int,
entity_description: TadoBinarySensorEntityDescription,
) -> None:
"""Initialize of the Tado Sensor."""
self.entity_description = entity_description
self._tado = tado
super().__init__(zone_name, tado.home_id, zone_id)
super().__init__(zone_name, coordinator.home_id, zone_id, coordinator)
self._attr_unique_id = f"{entity_description.key} {zone_id} {tado.home_id}"
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, "zone", self.zone_id
),
self._async_update_callback,
)
self._attr_unique_id = (
f"{entity_description.key} {zone_id} {coordinator.home_id}"
)
self._async_update_zone_data()
@callback
def _async_update_callback(self) -> None:
"""Update and write state."""
self._async_update_zone_data()
self.async_write_ha_state()
@callback
def _async_update_zone_data(self) -> None:
"""Handle update callbacks."""
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
try:
tado_zone_data = self._tado.data["zone"][self.zone_id]
tado_zone_data = self.coordinator.data["zone"][self.zone_id]
except KeyError:
return
@ -262,3 +223,4 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity):
self._attr_extra_state_attributes = self.entity_description.attributes_fn(
tado_zone_data
)
super()._handle_coordinator_update()

View File

@ -26,11 +26,10 @@ from homeassistant.components.climate import (
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 homeassistant.helpers.typing import VolDictType
from . import TadoConfigEntry, TadoConnector
from . import TadoConfigEntry
from .const import (
CONST_EXCLUSIVE_OVERLAY_GROUP,
CONST_FAN_AUTO,
@ -50,7 +49,6 @@ from .const import (
HA_TO_TADO_HVAC_MODE_MAP,
ORDERED_KNOWN_TADO_MODES,
PRESET_AUTO,
SIGNAL_TADO_UPDATE_RECEIVED,
SUPPORT_PRESET_AUTO,
SUPPORT_PRESET_MANUAL,
TADO_DEFAULT_MAX_TEMP,
@ -73,6 +71,7 @@ from .const import (
TYPE_AIR_CONDITIONING,
TYPE_HEATING,
)
from .coordinator import TadoDataUpdateCoordinator
from .entity import TadoZoneEntity
from .helper import decide_duration, decide_overlay_mode, generate_supported_fanmodes
@ -105,8 +104,8 @@ async def async_setup_entry(
) -> None:
"""Set up the Tado climate platform."""
tado = entry.runtime_data
entities = await hass.async_add_executor_job(_generate_entities, tado)
tado = entry.runtime_data.coordinator
entities = await _generate_entities(tado)
platform = entity_platform.async_get_current_platform()
@ -125,12 +124,12 @@ async def async_setup_entry(
async_add_entities(entities, True)
def _generate_entities(tado: TadoConnector) -> list[TadoClimate]:
async def _generate_entities(tado: TadoDataUpdateCoordinator) -> list[TadoClimate]:
"""Create all climate entities."""
entities = []
for zone in tado.zones:
if zone["type"] in [TYPE_HEATING, TYPE_AIR_CONDITIONING]:
entity = create_climate_entity(
entity = await create_climate_entity(
tado, zone["name"], zone["id"], zone["devices"][0]
)
if entity:
@ -138,11 +137,11 @@ def _generate_entities(tado: TadoConnector) -> list[TadoClimate]:
return entities
def create_climate_entity(
tado: TadoConnector, name: str, zone_id: int, device_info: dict
async def create_climate_entity(
tado: TadoDataUpdateCoordinator, name: str, zone_id: int, device_info: dict
) -> TadoClimate | None:
"""Create a Tado climate entity."""
capabilities = tado.get_capabilities(zone_id)
capabilities = await tado.get_capabilities(zone_id)
_LOGGER.debug("Capabilities for zone %s: %s", zone_id, capabilities)
zone_type = capabilities["type"]
@ -243,6 +242,8 @@ def create_climate_entity(
cool_max_temp = float(cool_temperatures["celsius"]["max"])
cool_step = cool_temperatures["celsius"].get("step", PRECISION_TENTHS)
auto_geofencing_supported = await tado.get_auto_geofencing_supported()
return TadoClimate(
tado,
name,
@ -251,6 +252,8 @@ def create_climate_entity(
supported_hvac_modes,
support_flags,
device_info,
capabilities,
auto_geofencing_supported,
heat_min_temp,
heat_max_temp,
heat_step,
@ -272,13 +275,15 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
def __init__(
self,
tado: TadoConnector,
coordinator: TadoDataUpdateCoordinator,
zone_name: str,
zone_id: int,
zone_type: str,
supported_hvac_modes: list[HVACMode],
support_flags: ClimateEntityFeature,
device_info: dict[str, str],
capabilities: dict[str, str],
auto_geofencing_supported: bool,
heat_min_temp: float | None = None,
heat_max_temp: float | None = None,
heat_step: float | None = None,
@ -289,13 +294,13 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
supported_swing_modes: list[str] | None = None,
) -> None:
"""Initialize of Tado climate entity."""
self._tado = tado
super().__init__(zone_name, tado.home_id, zone_id)
self._tado = coordinator
super().__init__(zone_name, coordinator.home_id, zone_id, coordinator)
self.zone_id = zone_id
self.zone_type = zone_type
self._attr_unique_id = f"{zone_type} {zone_id} {tado.home_id}"
self._attr_unique_id = f"{zone_type} {zone_id} {coordinator.home_id}"
self._device_info = device_info
self._device_id = self._device_info["shortSerialNo"]
@ -327,36 +332,61 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
self._current_tado_vertical_swing = TADO_SWING_OFF
self._current_tado_horizontal_swing = TADO_SWING_OFF
capabilities = tado.get_capabilities(zone_id)
self._current_tado_capabilities = capabilities
self._auto_geofencing_supported = auto_geofencing_supported
self._tado_zone_data: PyTado.TadoZone = {}
self._tado_geofence_data: dict[str, str] | None = None
self._tado_zone_temp_offset: dict[str, Any] = {}
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,
)
)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._async_update_zone_data()
super()._handle_coordinator_update()
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,
@callback
def _async_update_zone_data(self) -> None:
"""Load tado data into zone."""
self._tado_geofence_data = self._tado.data["geofence"]
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_hvac_mode = self._tado_zone_data.current_hvac_mode
self._current_tado_hvac_action = self._tado_zone_data.current_hvac_action
if self._is_valid_setting_for_hvac_mode(TADO_FANLEVEL_SETTING):
self._current_tado_fan_level = self._tado_zone_data.current_fan_level
if self._is_valid_setting_for_hvac_mode(TADO_FANSPEED_SETTING):
self._current_tado_fan_speed = self._tado_zone_data.current_fan_speed
if self._is_valid_setting_for_hvac_mode(TADO_SWING_SETTING):
self._current_tado_swing_mode = self._tado_zone_data.current_swing_mode
if self._is_valid_setting_for_hvac_mode(TADO_VERTICAL_SWING_SETTING):
self._current_tado_vertical_swing = (
self._tado_zone_data.current_vertical_swing_mode
)
)
if self._is_valid_setting_for_hvac_mode(TADO_HORIZONTAL_SWING_SETTING):
self._current_tado_horizontal_swing = (
self._tado_zone_data.current_horizontal_swing_mode
)
@callback
def _async_update_zone_callback(self) -> None:
"""Load tado data and update state."""
self._async_update_zone_data()
@property
def current_humidity(self) -> int | None:
@ -401,12 +431,13 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
return FAN_AUTO
return None
def set_fan_mode(self, fan_mode: str) -> None:
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Turn fan on/off."""
if self._is_valid_setting_for_hvac_mode(TADO_FANSPEED_SETTING):
self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP_LEGACY[fan_mode])
await self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP_LEGACY[fan_mode])
elif self._is_valid_setting_for_hvac_mode(TADO_FANLEVEL_SETTING):
self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP[fan_mode])
await self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP[fan_mode])
await self.coordinator.async_request_refresh()
@property
def preset_mode(self) -> str:
@ -425,13 +456,14 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
@property
def preset_modes(self) -> list[str]:
"""Return a list of available preset modes."""
if self._tado.get_auto_geofencing_supported():
if self._auto_geofencing_supported:
return SUPPORT_PRESET_AUTO
return SUPPORT_PRESET_MANUAL
def set_preset_mode(self, preset_mode: str) -> None:
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
self._tado.set_presence(preset_mode)
await self._tado.set_presence(preset_mode)
await self.coordinator.async_request_refresh()
@property
def target_temperature_step(self) -> float | None:
@ -449,7 +481,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
# the device is switching states
return self._tado_zone_data.target_temp or self._tado_zone_data.current_temp
def set_timer(
async def set_timer(
self,
temperature: float,
time_period: int | None = None,
@ -457,14 +489,15 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
):
"""Set the timer on the entity, and temperature if supported."""
self._control_hvac(
await self._control_hvac(
hvac_mode=CONST_MODE_HEAT,
target_temp=temperature,
duration=time_period,
overlay_mode=requested_overlay,
)
await self.coordinator.async_request_refresh()
def set_temp_offset(self, offset: float) -> None:
async def set_temp_offset(self, offset: float) -> None:
"""Set offset on the entity."""
_LOGGER.debug(
@ -474,8 +507,9 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
)
self._tado.set_temperature_offset(self._device_id, offset)
await self.coordinator.async_request_refresh()
def set_temperature(self, **kwargs: Any) -> None:
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
return
@ -485,15 +519,21 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
CONST_MODE_AUTO,
CONST_MODE_SMART_SCHEDULE,
):
self._control_hvac(target_temp=temperature)
await self._control_hvac(target_temp=temperature)
await self.coordinator.async_request_refresh()
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)
await self._control_hvac(target_temp=temperature, hvac_mode=new_hvac_mode)
await self.coordinator.async_request_refresh()
def set_hvac_mode(self, hvac_mode: HVACMode) -> None:
async def async_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])
_LOGGER.debug(
"Setting new hvac mode for device %s to %s", self._device_id, hvac_mode
)
await self._control_hvac(hvac_mode=HA_TO_TADO_HVAC_MODE_MAP[hvac_mode])
await self.coordinator.async_request_refresh()
@property
def available(self) -> bool:
@ -559,7 +599,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
)
return state_attr
def set_swing_mode(self, swing_mode: str) -> None:
async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set swing modes for the device."""
vertical_swing = None
horizontal_swing = None
@ -591,62 +631,12 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
if self._is_valid_setting_for_hvac_mode(TADO_HORIZONTAL_SWING_SETTING):
horizontal_swing = TADO_SWING_ON
self._control_hvac(
await self._control_hvac(
swing_mode=swing,
vertical_swing=vertical_swing,
horizontal_swing=horizontal_swing,
)
@callback
def _async_update_zone_data(self) -> None:
"""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_hvac_mode = self._tado_zone_data.current_hvac_mode
self._current_tado_hvac_action = self._tado_zone_data.current_hvac_action
if self._is_valid_setting_for_hvac_mode(TADO_FANLEVEL_SETTING):
self._current_tado_fan_level = self._tado_zone_data.current_fan_level
if self._is_valid_setting_for_hvac_mode(TADO_FANSPEED_SETTING):
self._current_tado_fan_speed = self._tado_zone_data.current_fan_speed
if self._is_valid_setting_for_hvac_mode(TADO_SWING_SETTING):
self._current_tado_swing_mode = self._tado_zone_data.current_swing_mode
if self._is_valid_setting_for_hvac_mode(TADO_VERTICAL_SWING_SETTING):
self._current_tado_vertical_swing = (
self._tado_zone_data.current_vertical_swing_mode
)
if self._is_valid_setting_for_hvac_mode(TADO_HORIZONTAL_SWING_SETTING):
self._current_tado_horizontal_swing = (
self._tado_zone_data.current_horizontal_swing_mode
)
@callback
def _async_update_zone_callback(self) -> None:
"""Load tado data and update state."""
self._async_update_zone_data()
self.async_write_ha_state()
@callback
def _async_update_home_data(self) -> None:
"""Load tado geofencing data into zone."""
self._tado_geofence_data = self._tado.data["geofence"]
@callback
def _async_update_home_callback(self) -> None:
"""Load tado data and update state."""
self._async_update_home_data()
self.async_write_ha_state()
await self.coordinator.async_request_refresh()
def _normalize_target_temp_for_hvac_mode(self) -> None:
def adjust_temp(min_temp, max_temp) -> float | None:
@ -665,7 +655,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
elif self._current_tado_hvac_mode == CONST_MODE_HEAT:
self._target_temp = adjust_temp(self._heat_min_temp, self._heat_max_temp)
def _control_hvac(
async def _control_hvac(
self,
hvac_mode: str | None = None,
target_temp: float | None = None,
@ -712,7 +702,9 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
_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)
await 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:
@ -721,17 +713,17 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
self.zone_name,
self.zone_id,
)
self._tado.reset_zone_overlay(self.zone_id)
await self._tado.reset_zone_overlay(self.zone_id)
return
overlay_mode = decide_overlay_mode(
tado=self._tado,
coordinator=self._tado,
duration=duration,
overlay_mode=overlay_mode,
zone_id=self.zone_id,
)
duration = decide_duration(
tado=self._tado,
coordinator=self._tado,
duration=duration,
zone_id=self.zone_id,
overlay_mode=overlay_mode,
@ -785,7 +777,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
):
swing = self._current_tado_swing_mode
self._tado.set_zone_overlay(
await 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,
@ -800,18 +792,23 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
)
def _is_valid_setting_for_hvac_mode(self, setting: str) -> bool:
return (
self._current_tado_capabilities.get(self._current_tado_hvac_mode, {}).get(
setting
)
is not None
"""Determine if a setting is valid for the current HVAC mode."""
capabilities: str | dict[str, str] = self._current_tado_capabilities.get(
self._current_tado_hvac_mode, {}
)
if isinstance(capabilities, dict):
return capabilities.get(setting) is not None
return False
def _is_current_setting_supported_by_current_hvac_mode(
self, setting: str, current_state: str | None
) -> bool:
if self._is_valid_setting_for_hvac_mode(setting):
return current_state in self._current_tado_capabilities[
self._current_tado_hvac_mode
].get(setting, [])
"""Determine if the current setting is supported by the current HVAC mode."""
capabilities: str | dict[str, str] = self._current_tado_capabilities.get(
self._current_tado_hvac_mode, {}
)
if isinstance(capabilities, dict) and self._is_valid_setting_for_hvac_mode(
setting
):
return current_state in capabilities.get(setting, [])
return False

View File

@ -0,0 +1,391 @@
"""Coordinator for the Tado integration."""
from __future__ import annotations
from datetime import datetime, timedelta
import logging
from typing import Any
from PyTado.interface import Tado
from requests import RequestException
from homeassistant.components.climate import PRESET_AWAY, PRESET_HOME
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
CONF_FALLBACK,
CONST_OVERLAY_TADO_DEFAULT,
DOMAIN,
INSIDE_TEMPERATURE_MEASUREMENT,
PRESET_AUTO,
TEMP_OFFSET,
)
_LOGGER = logging.getLogger(__name__)
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=4)
SCAN_INTERVAL = timedelta(minutes=5)
SCAN_MOBILE_DEVICE_INTERVAL = timedelta(seconds=30)
type TadoConfigEntry = ConfigEntry[TadoDataUpdateCoordinator]
class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]):
"""Class to manage API calls from and to Tado via PyTado."""
tado: Tado
home_id: int
home_name: str
config_entry: TadoConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
tado: Tado,
debug: bool = False,
) -> None:
"""Initialize the Tado data update coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
self._tado = tado
self._username = entry.data[CONF_USERNAME]
self._password = entry.data[CONF_PASSWORD]
self._fallback = entry.options.get(CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT)
self._debug = debug
self.home_id: int
self.home_name: str
self.zones: list[dict[Any, Any]] = []
self.devices: list[dict[Any, Any]] = []
self.data: dict[str, dict] = {
"device": {},
"weather": {},
"geofence": {},
"zone": {},
}
@property
def fallback(self) -> str:
"""Return fallback flag to Smart Schedule."""
return self._fallback
async def _async_update_data(self) -> dict[str, dict]:
"""Fetch the (initial) latest data from Tado."""
try:
_LOGGER.debug("Preloading home data")
tado_home_call = await self.hass.async_add_executor_job(self._tado.get_me)
_LOGGER.debug("Preloading zones and devices")
self.zones = await self.hass.async_add_executor_job(self._tado.get_zones)
self.devices = await self.hass.async_add_executor_job(
self._tado.get_devices
)
except RequestException as err:
raise UpdateFailed(f"Error during Tado setup: {err}") from err
tado_home = tado_home_call["homes"][0]
self.home_id = tado_home["id"]
self.home_name = tado_home["name"]
devices = await self._async_update_devices()
zones = await self._async_update_zones()
home = await self._async_update_home()
self.data["device"] = devices
self.data["zone"] = zones
self.data["weather"] = home["weather"]
self.data["geofence"] = home["geofence"]
return self.data
async def _async_update_devices(self) -> dict[str, dict]:
"""Update the device data from Tado."""
try:
devices = await self.hass.async_add_executor_job(self._tado.get_devices)
except RequestException as err:
_LOGGER.error("Error updating Tado devices: %s", err)
raise UpdateFailed(f"Error updating Tado devices: {err}") from err
if not devices:
_LOGGER.error("No linked devices found for home ID %s", self.home_id)
raise UpdateFailed(f"No linked devices found for home ID {self.home_id}")
return await self.hass.async_add_executor_job(self._update_device_info, devices)
def _update_device_info(self, devices: list[dict[str, Any]]) -> dict[str, dict]:
"""Update the device data from Tado."""
mapped_devices: dict[str, dict] = {}
for device in devices:
device_short_serial_no = device["shortSerialNo"]
_LOGGER.debug("Updating device %s", device_short_serial_no)
try:
if (
INSIDE_TEMPERATURE_MEASUREMENT
in device["characteristics"]["capabilities"]
):
_LOGGER.debug(
"Updating temperature offset for device %s",
device_short_serial_no,
)
device[TEMP_OFFSET] = self._tado.get_device_info(
device_short_serial_no, TEMP_OFFSET
)
except RequestException as err:
_LOGGER.error(
"Error updating device %s: %s", device_short_serial_no, err
)
_LOGGER.debug(
"Device %s updated, with data: %s", device_short_serial_no, device
)
mapped_devices[device_short_serial_no] = device
return mapped_devices
async def _async_update_zones(self) -> dict[int, dict]:
"""Update the zone data from Tado."""
try:
zone_states_call = await self.hass.async_add_executor_job(
self._tado.get_zone_states
)
zone_states = zone_states_call["zoneStates"]
except RequestException as err:
_LOGGER.error("Error updating Tado zones: %s", err)
raise UpdateFailed(f"Error updating Tado zones: {err}") from err
mapped_zones: dict[int, dict] = {}
for zone in zone_states:
mapped_zones[int(zone)] = await self._update_zone(int(zone))
return mapped_zones
async def _update_zone(self, zone_id: int) -> dict[str, str]:
"""Update the internal data of a zone."""
_LOGGER.debug("Updating zone %s", zone_id)
try:
data = await self.hass.async_add_executor_job(
self._tado.get_zone_state, zone_id
)
except RequestException as err:
_LOGGER.error("Error updating Tado zone %s: %s", zone_id, err)
raise UpdateFailed(f"Error updating Tado zone {zone_id}: {err}") from err
_LOGGER.debug("Zone %s updated, with data: %s", zone_id, data)
return data
async def _async_update_home(self) -> dict[str, dict]:
"""Update the home data from Tado."""
try:
weather = await self.hass.async_add_executor_job(self._tado.get_weather)
geofence = await self.hass.async_add_executor_job(self._tado.get_home_state)
except RequestException as err:
_LOGGER.error("Error updating Tado home: %s", err)
raise UpdateFailed(f"Error updating Tado home: {err}") from err
_LOGGER.debug(
"Home data updated, with weather and geofence data: %s, %s",
weather,
geofence,
)
return {"weather": weather, "geofence": geofence}
async def get_capabilities(self, zone_id: int | str) -> dict:
"""Fetch the capabilities from Tado."""
try:
return await self.hass.async_add_executor_job(
self._tado.get_capabilities, zone_id
)
except RequestException as err:
raise UpdateFailed(f"Error updating Tado data: {err}") from err
async def get_auto_geofencing_supported(self) -> bool:
"""Fetch the auto geofencing supported from Tado."""
try:
return await self.hass.async_add_executor_job(
self._tado.get_auto_geofencing_supported
)
except RequestException as err:
raise UpdateFailed(f"Error updating Tado data: {err}") from err
async def reset_zone_overlay(self, zone_id):
"""Reset the zone back to the default operation."""
try:
await self.hass.async_add_executor_job(
self._tado.reset_zone_overlay, zone_id
)
await self._update_zone(zone_id)
except RequestException as err:
raise UpdateFailed(f"Error resetting Tado data: {err}") from err
async def set_presence(
self,
presence=PRESET_HOME,
):
"""Set the presence to home, away or auto."""
if presence == PRESET_AWAY:
await self.hass.async_add_executor_job(self._tado.set_away)
elif presence == PRESET_HOME:
await self.hass.async_add_executor_job(self._tado.set_home)
elif presence == PRESET_AUTO:
await self.hass.async_add_executor_job(self._tado.set_auto)
async def set_zone_overlay(
self,
zone_id=None,
overlay_mode=None,
temperature=None,
duration=None,
device_type="HEATING",
mode=None,
fan_speed=None,
swing=None,
fan_level=None,
vertical_swing=None,
horizontal_swing=None,
) -> None:
"""Set a zone overlay."""
_LOGGER.debug(
"Set overlay for zone %s: overlay_mode=%s, temp=%s, duration=%s, type=%s, mode=%s, fan_speed=%s, swing=%s, fan_level=%s, vertical_swing=%s, horizontal_swing=%s",
zone_id,
overlay_mode,
temperature,
duration,
device_type,
mode,
fan_speed,
swing,
fan_level,
vertical_swing,
horizontal_swing,
)
try:
await self.hass.async_add_executor_job(
self._tado.set_zone_overlay,
zone_id,
overlay_mode,
temperature,
duration,
device_type,
"ON",
mode,
fan_speed,
swing,
fan_level,
vertical_swing,
horizontal_swing,
)
except RequestException as err:
raise UpdateFailed(f"Error setting Tado overlay: {err}") from err
await self._update_zone(zone_id)
async def set_zone_off(self, zone_id, overlay_mode, device_type="HEATING"):
"""Set a zone to off."""
try:
await self.hass.async_add_executor_job(
self._tado.set_zone_overlay,
zone_id,
overlay_mode,
None,
None,
device_type,
"OFF",
)
except RequestException as err:
raise UpdateFailed(f"Error setting Tado overlay: {err}") from err
await self._update_zone(zone_id)
async def set_temperature_offset(self, device_id, offset):
"""Set temperature offset of device."""
try:
await self.hass.async_add_executor_job(
self._tado.set_temp_offset, device_id, offset
)
except RequestException as err:
raise UpdateFailed(f"Error setting Tado temperature offset: {err}") from err
async def set_meter_reading(self, reading: int) -> dict[str, Any]:
"""Send meter reading to Tado."""
dt: str = datetime.now().strftime("%Y-%m-%d")
if self._tado is None:
raise HomeAssistantError("Tado client is not initialized")
try:
return await self.hass.async_add_executor_job(
self._tado.set_eiq_meter_readings, dt, reading
)
except RequestException as err:
raise UpdateFailed(f"Error setting Tado meter reading: {err}") from err
class TadoMobileDeviceUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]):
"""Class to manage the mobile devices from Tado via PyTado."""
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
tado: Tado,
) -> None:
"""Initialize the Tado data update coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=SCAN_MOBILE_DEVICE_INTERVAL,
)
self._tado = tado
self.data: dict[str, dict] = {}
async def _async_update_data(self) -> dict[str, dict]:
"""Fetch the latest data from Tado."""
try:
mobile_devices = await self.hass.async_add_executor_job(
self._tado.get_mobile_devices
)
except RequestException as err:
_LOGGER.error("Error updating Tado mobile devices: %s", err)
raise UpdateFailed(f"Error updating Tado mobile devices: {err}") from err
mapped_mobile_devices: dict[str, dict] = {}
for mobile_device in mobile_devices:
mobile_device_id = mobile_device["id"]
_LOGGER.debug("Updating mobile device %s", mobile_device_id)
try:
mapped_mobile_devices[mobile_device_id] = mobile_device
_LOGGER.debug(
"Mobile device %s updated, with data: %s",
mobile_device_id,
mobile_device,
)
except RequestException:
_LOGGER.error(
"Unable to connect to Tado while updating mobile device %s",
mobile_device_id,
)
self.data["mobile_device"] = mapped_mobile_devices
return self.data

View File

@ -11,12 +11,15 @@ from homeassistant.components.device_tracker import (
from homeassistant.const import STATE_HOME, STATE_NOT_HOME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from . import TadoConfigEntry
from .const import DOMAIN, SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED
from .tado_connector import TadoConnector
from .const import DOMAIN
from .coordinator import TadoMobileDeviceUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@ -28,7 +31,7 @@ async def async_setup_entry(
) -> None:
"""Set up the Tado device scannery entity."""
_LOGGER.debug("Setting up Tado device scanner entity")
tado = entry.runtime_data
tado = entry.runtime_data.mobile_coordinator
tracked: set = set()
# Fix non-string unique_id for device trackers
@ -49,58 +52,56 @@ async def async_setup_entry(
update_devices()
entry.async_on_unload(
async_dispatcher_connect(
hass,
SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED.format(tado.home_id),
update_devices,
)
)
@callback
def add_tracked_entities(
hass: HomeAssistant,
tado: TadoConnector,
coordinator: TadoMobileDeviceUpdateCoordinator,
async_add_entities: AddEntitiesCallback,
tracked: set[str],
) -> None:
"""Add new tracker entities from Tado."""
_LOGGER.debug("Fetching Tado devices from API for (newly) tracked entities")
new_tracked = []
for device_key, device in tado.data["mobile_device"].items():
for device_key, device in coordinator.data["mobile_device"].items():
if device_key in tracked:
continue
_LOGGER.debug(
"Adding Tado device %s with deviceID %s", device["name"], device_key
)
new_tracked.append(TadoDeviceTrackerEntity(device_key, device["name"], tado))
new_tracked.append(
TadoDeviceTrackerEntity(device_key, device["name"], coordinator)
)
tracked.add(device_key)
async_add_entities(new_tracked)
class TadoDeviceTrackerEntity(TrackerEntity):
class TadoDeviceTrackerEntity(CoordinatorEntity[DataUpdateCoordinator], TrackerEntity):
"""A Tado Device Tracker entity."""
_attr_should_poll = False
_attr_available = False
def __init__(
self,
device_id: str,
device_name: str,
tado: TadoConnector,
coordinator: TadoMobileDeviceUpdateCoordinator,
) -> None:
"""Initialize a Tado Device Tracker entity."""
super().__init__()
super().__init__(coordinator)
self._attr_unique_id = str(device_id)
self._device_id = device_id
self._device_name = device_name
self._tado = tado
self._active = False
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.update_state()
super()._handle_coordinator_update()
@callback
def update_state(self) -> None:
"""Update the Tado device."""
@ -109,7 +110,7 @@ class TadoDeviceTrackerEntity(TrackerEntity):
self._device_name,
self._device_id,
)
device = self._tado.data["mobile_device"][self._device_id]
device = self.coordinator.data["mobile_device"][self._device_id]
self._attr_available = False
_LOGGER.debug(
@ -129,25 +130,6 @@ class TadoDeviceTrackerEntity(TrackerEntity):
else:
_LOGGER.debug("Tado device %s is not at home", device["name"])
@callback
def on_demand_update(self) -> None:
"""Update state on demand."""
self.update_state()
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Register state update callback."""
_LOGGER.debug("Registering Tado device tracker entity")
self.async_on_remove(
async_dispatcher_connect(
self.hass,
SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED.format(self._tado.home_id),
self.on_demand_update,
)
)
self.update_state()
@property
def name(self) -> str:
"""Return the name of the device."""

View File

@ -1,21 +1,30 @@
"""Base class for Tado entity."""
import logging
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import TadoConnector
from .const import DEFAULT_NAME, DOMAIN, TADO_HOME, TADO_ZONE
from .coordinator import TadoDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
class TadoDeviceEntity(Entity):
"""Base implementation for Tado device."""
class TadoCoordinatorEntity(CoordinatorEntity[TadoDataUpdateCoordinator]):
"""Base class for Tado entity."""
_attr_should_poll = False
_attr_has_entity_name = True
def __init__(self, device_info: dict[str, str]) -> None:
class TadoDeviceEntity(TadoCoordinatorEntity):
"""Base implementation for Tado device."""
def __init__(
self, device_info: dict[str, str], coordinator: TadoDataUpdateCoordinator
) -> None:
"""Initialize a Tado device."""
super().__init__()
super().__init__(coordinator)
self._device_info = device_info
self.device_name = device_info["serialNo"]
self.device_id = device_info["shortSerialNo"]
@ -30,35 +39,35 @@ class TadoDeviceEntity(Entity):
)
class TadoHomeEntity(Entity):
class TadoHomeEntity(TadoCoordinatorEntity):
"""Base implementation for Tado home."""
_attr_should_poll = False
_attr_has_entity_name = True
def __init__(self, tado: TadoConnector) -> None:
def __init__(self, coordinator: TadoDataUpdateCoordinator) -> None:
"""Initialize a Tado home."""
super().__init__()
self.home_name = tado.home_name
self.home_id = tado.home_id
super().__init__(coordinator)
self.home_name = coordinator.home_name
self.home_id = coordinator.home_id
self._attr_device_info = DeviceInfo(
configuration_url="https://app.tado.com",
identifiers={(DOMAIN, str(tado.home_id))},
identifiers={(DOMAIN, str(coordinator.home_id))},
manufacturer=DEFAULT_NAME,
model=TADO_HOME,
name=tado.home_name,
name=coordinator.home_name,
)
class TadoZoneEntity(Entity):
class TadoZoneEntity(TadoCoordinatorEntity):
"""Base implementation for Tado zone."""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(self, zone_name: str, home_id: int, zone_id: int) -> None:
def __init__(
self,
zone_name: str,
home_id: int,
zone_id: int,
coordinator: TadoDataUpdateCoordinator,
) -> None:
"""Initialize a Tado zone."""
super().__init__()
super().__init__(coordinator)
self.zone_name = zone_name
self.zone_id = zone_id
self._attr_device_info = DeviceInfo(

View File

@ -5,26 +5,27 @@ from .const import (
CONST_OVERLAY_TADO_MODE,
CONST_OVERLAY_TIMER,
)
from .tado_connector import TadoConnector
from .coordinator import TadoDataUpdateCoordinator
def decide_overlay_mode(
tado: TadoConnector,
coordinator: TadoDataUpdateCoordinator,
duration: int | None,
zone_id: int,
overlay_mode: str | None = None,
) -> str:
"""Return correct overlay mode based on the action and defaults."""
# If user gave duration then overlay mode needs to be timer
if duration:
return CONST_OVERLAY_TIMER
# If no duration or timer set to fallback setting
if overlay_mode is None:
overlay_mode = tado.fallback or CONST_OVERLAY_TADO_MODE
overlay_mode = coordinator.fallback or CONST_OVERLAY_TADO_MODE
# If default is Tado default then look it up
if overlay_mode == CONST_OVERLAY_TADO_DEFAULT:
overlay_mode = (
tado.data["zone"][zone_id].default_overlay_termination_type
coordinator.data["zone"][zone_id].default_overlay_termination_type
or CONST_OVERLAY_TADO_MODE
)
@ -32,18 +33,19 @@ def decide_overlay_mode(
def decide_duration(
tado: TadoConnector,
coordinator: TadoDataUpdateCoordinator,
duration: int | None,
zone_id: int,
overlay_mode: str | None = None,
) -> None | int:
"""Return correct duration based on the selected overlay mode/duration and tado config."""
# If we ended up with a timer but no duration, set a default duration
# 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 = (
int(tado.data["zone"][zone_id].default_overlay_termination_duration)
if tado.data["zone"][zone_id].default_overlay_termination_duration
int(coordinator.data["zone"][zone_id].default_overlay_termination_duration)
if coordinator.data["zone"][zone_id].default_overlay_termination_duration
is not None
else 3600
)
@ -53,6 +55,7 @@ def decide_duration(
def generate_supported_fanmodes(tado_to_ha_mapping: dict[str, str], options: list[str]):
"""Return correct list of fan modes or None."""
supported_fanmodes = [
tado_to_ha_mapping.get(option)
for option in options

View File

@ -0,0 +1,13 @@
"""Models for use in Tado integration."""
from dataclasses import dataclass
from .coordinator import TadoDataUpdateCoordinator, TadoMobileDeviceUpdateCoordinator
@dataclass
class TadoData:
"""Class to hold Tado data."""
coordinator: TadoDataUpdateCoordinator
mobile_coordinator: TadoMobileDeviceUpdateCoordinator

View File

@ -15,7 +15,6 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
@ -24,13 +23,12 @@ from .const import (
CONDITIONS_MAP,
SENSOR_DATA_CATEGORY_GEOFENCE,
SENSOR_DATA_CATEGORY_WEATHER,
SIGNAL_TADO_UPDATE_RECEIVED,
TYPE_AIR_CONDITIONING,
TYPE_HEATING,
TYPE_HOT_WATER,
)
from .coordinator import TadoDataUpdateCoordinator
from .entity import TadoHomeEntity, TadoZoneEntity
from .tado_connector import TadoConnector
_LOGGER = logging.getLogger(__name__)
@ -197,7 +195,7 @@ async def async_setup_entry(
) -> None:
"""Set up the Tado sensor platform."""
tado = entry.runtime_data
tado = entry.runtime_data.coordinator
zones = tado.zones
entities: list[SensorEntity] = []
@ -232,39 +230,22 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity):
entity_description: TadoSensorEntityDescription
def __init__(
self, tado: TadoConnector, entity_description: TadoSensorEntityDescription
self,
coordinator: TadoDataUpdateCoordinator,
entity_description: TadoSensorEntityDescription,
) -> None:
"""Initialize of the Tado Sensor."""
self.entity_description = entity_description
super().__init__(tado)
self._tado = tado
super().__init__(coordinator)
self._attr_unique_id = f"{entity_description.key} {tado.home_id}"
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_callback,
)
)
self._async_update_home_data()
self._attr_unique_id = f"{entity_description.key} {coordinator.home_id}"
@callback
def _async_update_callback(self) -> None:
"""Update and write state."""
self._async_update_home_data()
self.async_write_ha_state()
@callback
def _async_update_home_data(self) -> None:
"""Handle update callbacks."""
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
try:
tado_weather_data = self._tado.data["weather"]
tado_geofence_data = self._tado.data["geofence"]
tado_weather_data = self.coordinator.data["weather"]
tado_geofence_data = self.coordinator.data["geofence"]
except KeyError:
return
@ -278,6 +259,7 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity):
self._attr_extra_state_attributes = self.entity_description.attributes_fn(
tado_sensor_data
)
super()._handle_coordinator_update()
class TadoZoneSensor(TadoZoneEntity, SensorEntity):
@ -287,43 +269,24 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity):
def __init__(
self,
tado: TadoConnector,
coordinator: TadoDataUpdateCoordinator,
zone_name: str,
zone_id: int,
entity_description: TadoSensorEntityDescription,
) -> None:
"""Initialize of the Tado Sensor."""
self.entity_description = entity_description
self._tado = tado
super().__init__(zone_name, tado.home_id, zone_id)
super().__init__(zone_name, coordinator.home_id, zone_id, coordinator)
self._attr_unique_id = f"{entity_description.key} {zone_id} {tado.home_id}"
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, "zone", self.zone_id
),
self._async_update_callback,
)
self._attr_unique_id = (
f"{entity_description.key} {zone_id} {coordinator.home_id}"
)
self._async_update_zone_data()
@callback
def _async_update_callback(self) -> None:
"""Update and write state."""
self._async_update_zone_data()
self.async_write_ha_state()
@callback
def _async_update_zone_data(self) -> None:
"""Handle update callbacks."""
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
try:
tado_zone_data = self._tado.data["zone"][self.zone_id]
tado_zone_data = self.coordinator.data["zone"][self.zone_id]
except KeyError:
return
@ -332,3 +295,4 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity):
self._attr_extra_state_attributes = self.entity_description.attributes_fn(
tado_zone_data
)
super()._handle_coordinator_update()

View File

@ -43,11 +43,8 @@ def setup_services(hass: HomeAssistant) -> None:
if entry is None:
raise ServiceValidationError("Config entry not found")
tadoconnector = entry.runtime_data
response: dict = await hass.async_add_executor_job(
tadoconnector.set_meter_reading, call.data[CONF_READING]
)
coordinator = entry.runtime_data.coordinator
response: dict = await coordinator.set_meter_reading(call.data[CONF_READING])
if ATTR_MESSAGE in response:
raise HomeAssistantError(response[ATTR_MESSAGE])

View File

@ -1,332 +0,0 @@
"""Tado Connector a class to store the data as an object."""
from datetime import datetime, timedelta
import logging
from typing import Any
from PyTado.interface import Tado
from requests import RequestException
from homeassistant.components.climate import PRESET_AWAY, PRESET_HOME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.util import Throttle
from .const import (
INSIDE_TEMPERATURE_MEASUREMENT,
PRESET_AUTO,
SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED,
SIGNAL_TADO_UPDATE_RECEIVED,
TEMP_OFFSET,
)
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=4)
SCAN_INTERVAL = timedelta(minutes=5)
SCAN_MOBILE_DEVICE_INTERVAL = timedelta(seconds=30)
_LOGGER = logging.getLogger(__name__)
class TadoConnector:
"""An object to store the Tado data."""
def __init__(
self, hass: HomeAssistant, username: str, password: str, fallback: str
) -> None:
"""Initialize Tado Connector."""
self.hass = hass
self._username = username
self._password = password
self._fallback = fallback
self.home_id: int = 0
self.home_name = None
self.tado = None
self.zones: list[dict[Any, Any]] = []
self.devices: list[dict[Any, Any]] = []
self.data: dict[str, dict] = {
"device": {},
"mobile_device": {},
"weather": {},
"geofence": {},
"zone": {},
}
@property
def fallback(self):
"""Return fallback flag to Smart Schedule."""
return self._fallback
def setup(self):
"""Connect to Tado and fetch the zones."""
self.tado = Tado(self._username, self._password)
# Load zones and devices
self.zones = self.tado.get_zones()
self.devices = self.tado.get_devices()
tado_home = self.tado.get_me()["homes"][0]
self.home_id = tado_home["id"]
self.home_name = tado_home["name"]
def get_mobile_devices(self):
"""Return the Tado mobile devices."""
return self.tado.get_mobile_devices()
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self):
"""Update the registered zones."""
self.update_devices()
self.update_mobile_devices()
self.update_zones()
self.update_home()
def update_mobile_devices(self) -> None:
"""Update the mobile devices."""
try:
mobile_devices = self.get_mobile_devices()
except RuntimeError:
_LOGGER.error("Unable to connect to Tado while updating mobile devices")
return
if not mobile_devices:
_LOGGER.debug("No linked mobile devices found for home ID %s", self.home_id)
return
# Errors are planned to be converted to exceptions
# in PyTado library, so this can be removed
if isinstance(mobile_devices, dict) and mobile_devices.get("errors"):
_LOGGER.error(
"Error for home ID %s while updating mobile devices: %s",
self.home_id,
mobile_devices["errors"],
)
return
for mobile_device in mobile_devices:
self.data["mobile_device"][mobile_device["id"]] = mobile_device
_LOGGER.debug(
"Dispatching update to %s mobile device: %s",
self.home_id,
mobile_device,
)
dispatcher_send(
self.hass,
SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED.format(self.home_id),
)
def update_devices(self):
"""Update the device data from Tado."""
try:
devices = self.tado.get_devices()
except RuntimeError:
_LOGGER.error("Unable to connect to Tado while updating devices")
return
if not devices:
_LOGGER.debug("No linked devices found for home ID %s", self.home_id)
return
# Errors are planned to be converted to exceptions
# in PyTado library, so this can be removed
if isinstance(devices, dict) and devices.get("errors"):
_LOGGER.error(
"Error for home ID %s while updating devices: %s",
self.home_id,
devices["errors"],
)
return
for device in devices:
device_short_serial_no = device["shortSerialNo"]
_LOGGER.debug("Updating device %s", device_short_serial_no)
try:
if (
INSIDE_TEMPERATURE_MEASUREMENT
in device["characteristics"]["capabilities"]
):
device[TEMP_OFFSET] = self.tado.get_device_info(
device_short_serial_no, TEMP_OFFSET
)
except RuntimeError:
_LOGGER.error(
"Unable to connect to Tado while updating device %s",
device_short_serial_no,
)
return
self.data["device"][device_short_serial_no] = device
_LOGGER.debug(
"Dispatching update to %s device %s: %s",
self.home_id,
device_short_serial_no,
device,
)
dispatcher_send(
self.hass,
SIGNAL_TADO_UPDATE_RECEIVED.format(
self.home_id, "device", device_short_serial_no
),
)
def update_zones(self):
"""Update the zone data from Tado."""
try:
zone_states = self.tado.get_zone_states()["zoneStates"]
except RuntimeError:
_LOGGER.error("Unable to connect to Tado while updating zones")
return
for zone in zone_states:
self.update_zone(int(zone))
def update_zone(self, zone_id):
"""Update the internal data from Tado."""
_LOGGER.debug("Updating zone %s", zone_id)
try:
data = self.tado.get_zone_state(zone_id)
except RuntimeError:
_LOGGER.error("Unable to connect to Tado while updating zone %s", zone_id)
return
self.data["zone"][zone_id] = data
_LOGGER.debug(
"Dispatching update to %s zone %s: %s",
self.home_id,
zone_id,
data,
)
dispatcher_send(
self.hass,
SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "zone", zone_id),
)
def update_home(self):
"""Update the home data from Tado."""
try:
self.data["weather"] = self.tado.get_weather()
self.data["geofence"] = self.tado.get_home_state()
dispatcher_send(
self.hass,
SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "home", "data"),
)
except RuntimeError:
_LOGGER.error(
"Unable to connect to Tado while updating weather and geofence data"
)
return
def get_capabilities(self, zone_id):
"""Return the capabilities of the devices."""
return self.tado.get_capabilities(zone_id)
def get_auto_geofencing_supported(self):
"""Return whether the Tado Home supports auto geofencing."""
return self.tado.get_auto_geofencing_supported()
def reset_zone_overlay(self, zone_id):
"""Reset the zone back to the default operation."""
self.tado.reset_zone_overlay(zone_id)
self.update_zone(zone_id)
def set_presence(
self,
presence=PRESET_HOME,
):
"""Set the presence to home, away or auto."""
if presence == PRESET_AWAY:
self.tado.set_away()
elif presence == PRESET_HOME:
self.tado.set_home()
elif presence == PRESET_AUTO:
self.tado.set_auto()
# Update everything when changing modes
self.update_zones()
self.update_home()
def set_zone_overlay(
self,
zone_id=None,
overlay_mode=None,
temperature=None,
duration=None,
device_type="HEATING",
mode=None,
fan_speed=None,
swing=None,
fan_level=None,
vertical_swing=None,
horizontal_swing=None,
):
"""Set a zone overlay."""
_LOGGER.debug(
(
"Set overlay for zone %s: overlay_mode=%s, temp=%s, duration=%s,"
" type=%s, mode=%s fan_speed=%s swing=%s fan_level=%s vertical_swing=%s horizontal_swing=%s"
),
zone_id,
overlay_mode,
temperature,
duration,
device_type,
mode,
fan_speed,
swing,
fan_level,
vertical_swing,
horizontal_swing,
)
try:
self.tado.set_zone_overlay(
zone_id,
overlay_mode,
temperature,
duration,
device_type,
"ON",
mode,
fan_speed=fan_speed,
swing=swing,
fan_level=fan_level,
vertical_swing=vertical_swing,
horizontal_swing=horizontal_swing,
)
except RequestException as exc:
_LOGGER.error("Could not set zone overlay: %s", exc)
self.update_zone(zone_id)
def set_zone_off(self, zone_id, overlay_mode, device_type="HEATING"):
"""Set a zone to off."""
try:
self.tado.set_zone_overlay(
zone_id, overlay_mode, None, None, device_type, "OFF"
)
except RequestException as exc:
_LOGGER.error("Could not set zone overlay: %s", exc)
self.update_zone(zone_id)
def set_temperature_offset(self, device_id, offset):
"""Set temperature offset of device."""
try:
self.tado.set_temp_offset(device_id, offset)
except RequestException as exc:
_LOGGER.error("Could not set temperature offset: %s", exc)
def set_meter_reading(self, reading: int) -> dict[str, Any]:
"""Send meter reading to Tado."""
dt: str = datetime.now().strftime("%Y-%m-%d")
if self.tado is None:
raise HomeAssistantError("Tado client is not initialized")
try:
return self.tado.set_eiq_meter_readings(date=dt, reading=reading)
except RequestException as exc:
raise HomeAssistantError("Could not set meter reading") from exc

View File

@ -12,7 +12,6 @@ from homeassistant.components.water_heater import (
from homeassistant.const import ATTR_TEMPERATURE, 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 homeassistant.helpers.typing import VolDictType
@ -26,13 +25,12 @@ from .const import (
CONST_OVERLAY_MANUAL,
CONST_OVERLAY_TADO_MODE,
CONST_OVERLAY_TIMER,
SIGNAL_TADO_UPDATE_RECEIVED,
TYPE_HOT_WATER,
)
from .coordinator import TadoDataUpdateCoordinator
from .entity import TadoZoneEntity
from .helper import decide_duration, decide_overlay_mode
from .repairs import manage_water_heater_fallback_issue
from .tado_connector import TadoConnector
_LOGGER = logging.getLogger(__name__)
@ -67,8 +65,9 @@ async def async_setup_entry(
) -> None:
"""Set up the Tado water heater platform."""
tado = entry.runtime_data
entities = await hass.async_add_executor_job(_generate_entities, tado)
data = entry.runtime_data
coordinator = data.coordinator
entities = await _generate_entities(coordinator)
platform = entity_platform.async_get_current_platform()
@ -83,27 +82,29 @@ async def async_setup_entry(
manage_water_heater_fallback_issue(
hass=hass,
water_heater_names=[e.zone_name for e in entities],
integration_overlay_fallback=tado.fallback,
integration_overlay_fallback=coordinator.fallback,
)
def _generate_entities(tado: TadoConnector) -> list:
async def _generate_entities(coordinator: TadoDataUpdateCoordinator) -> list:
"""Create all water heater entities."""
entities = []
for zone in tado.zones:
for zone in coordinator.zones:
if zone["type"] == TYPE_HOT_WATER:
entity = create_water_heater_entity(
tado, zone["name"], zone["id"], str(zone["name"])
entity = await create_water_heater_entity(
coordinator, zone["name"], zone["id"], str(zone["name"])
)
entities.append(entity)
return entities
def create_water_heater_entity(tado: TadoConnector, name: str, zone_id: int, zone: str):
async def create_water_heater_entity(
coordinator: TadoDataUpdateCoordinator, name: str, zone_id: int, zone: str
):
"""Create a Tado water heater device."""
capabilities = tado.get_capabilities(zone_id)
capabilities = await coordinator.get_capabilities(zone_id)
supports_temperature_control = capabilities["canSetTemperature"]
@ -116,7 +117,7 @@ def create_water_heater_entity(tado: TadoConnector, name: str, zone_id: int, zon
max_temp = None
return TadoWaterHeater(
tado,
coordinator,
name,
zone_id,
supports_temperature_control,
@ -134,7 +135,7 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity):
def __init__(
self,
tado: TadoConnector,
coordinator: TadoDataUpdateCoordinator,
zone_name: str,
zone_id: int,
supports_temperature_control: bool,
@ -142,11 +143,10 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity):
max_temp,
) -> None:
"""Initialize of Tado water heater entity."""
self._tado = tado
super().__init__(zone_name, tado.home_id, zone_id)
super().__init__(zone_name, coordinator.home_id, zone_id, coordinator)
self.zone_id = zone_id
self._attr_unique_id = f"{zone_id} {tado.home_id}"
self._attr_unique_id = f"{zone_id} {coordinator.home_id}"
self._device_is_active = False
@ -164,19 +164,14 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity):
self._overlay_mode = CONST_MODE_SMART_SCHEDULE
self._tado_zone_data: Any = None
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, "zone", self.zone_id
),
self._async_update_callback,
)
)
self._async_update_data()
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._async_update_data()
super()._handle_coordinator_update()
@property
def current_operation(self) -> str | None:
"""Return current readable operation mode."""
@ -202,7 +197,7 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity):
"""Return the maximum temperature."""
return self._max_temperature
def set_operation_mode(self, operation_mode: str) -> None:
async def async_set_operation_mode(self, operation_mode: str) -> None:
"""Set new operation mode."""
mode = None
@ -213,18 +208,20 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity):
elif operation_mode == MODE_HEAT:
mode = CONST_MODE_HEAT
self._control_heater(hvac_mode=mode)
await self._control_heater(hvac_mode=mode)
await self.coordinator.async_request_refresh()
def set_timer(self, time_period: int, temperature: float | None = None):
async def set_timer(self, time_period: int, temperature: float | None = None):
"""Set the timer on the entity, and temperature if supported."""
if not self._supports_temperature_control and temperature is not None:
temperature = None
self._control_heater(
await self._control_heater(
hvac_mode=CONST_MODE_HEAT, target_temp=temperature, duration=time_period
)
await self.coordinator.async_request_refresh()
def set_temperature(self, **kwargs: Any) -> None:
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
temperature = kwargs.get(ATTR_TEMPERATURE)
if not self._supports_temperature_control or temperature is None:
@ -235,10 +232,11 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity):
CONST_MODE_AUTO,
CONST_MODE_SMART_SCHEDULE,
):
self._control_heater(target_temp=temperature)
await self._control_heater(target_temp=temperature)
return
self._control_heater(target_temp=temperature, hvac_mode=CONST_MODE_HEAT)
await self._control_heater(target_temp=temperature, hvac_mode=CONST_MODE_HEAT)
await self.coordinator.async_request_refresh()
@callback
def _async_update_callback(self) -> None:
@ -250,10 +248,10 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity):
def _async_update_data(self) -> None:
"""Load tado data."""
_LOGGER.debug("Updating water_heater platform for zone %d", self.zone_id)
self._tado_zone_data = self._tado.data["zone"][self.zone_id]
self._tado_zone_data = self.coordinator.data["zone"][self.zone_id]
self._current_tado_hvac_mode = self._tado_zone_data.current_hvac_mode
def _control_heater(
async def _control_heater(
self,
hvac_mode: str | None = None,
target_temp: float | None = None,
@ -276,23 +274,26 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity):
self.zone_name,
self.zone_id,
)
self._tado.reset_zone_overlay(self.zone_id)
await self.coordinator.reset_zone_overlay(self.zone_id)
await self.coordinator.async_request_refresh()
return
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, TYPE_HOT_WATER)
await self.coordinator.set_zone_off(
self.zone_id, CONST_OVERLAY_MANUAL, TYPE_HOT_WATER
)
return
overlay_mode = decide_overlay_mode(
tado=self._tado,
coordinator=self.coordinator,
duration=duration,
zone_id=self.zone_id,
)
duration = decide_duration(
tado=self._tado,
coordinator=self.coordinator,
duration=duration,
zone_id=self.zone_id,
overlay_mode=overlay_mode,
@ -304,7 +305,7 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity):
self.zone_id,
self._target_temp,
)
self._tado.set_zone_overlay(
await self.coordinator.set_zone_overlay(
zone_id=self.zone_id,
overlay_mode=overlay_mode,
temperature=self._target_temp,

View File

@ -0,0 +1,115 @@
# serializer version: 1
# name: test_aircon_set_hvac_mode[cool-COOL]
_Call(
tuple(
3,
'NEXT_TIME_BLOCK',
24.76,
None,
'AIR_CONDITIONING',
'ON',
'COOL',
'AUTO',
None,
None,
None,
None,
),
dict({
}),
)
# ---
# name: test_aircon_set_hvac_mode[dry-DRY]
_Call(
tuple(
3,
'NEXT_TIME_BLOCK',
24.76,
None,
'AIR_CONDITIONING',
'ON',
'DRY',
None,
None,
None,
None,
None,
),
dict({
}),
)
# ---
# name: test_aircon_set_hvac_mode[fan_only-FAN]
_Call(
tuple(
3,
'NEXT_TIME_BLOCK',
None,
None,
'AIR_CONDITIONING',
'ON',
'FAN',
None,
None,
None,
None,
None,
),
dict({
}),
)
# ---
# name: test_aircon_set_hvac_mode[heat-HEAT]
_Call(
tuple(
3,
'NEXT_TIME_BLOCK',
24.76,
None,
'AIR_CONDITIONING',
'ON',
'HEAT',
'AUTO',
None,
None,
None,
None,
),
dict({
}),
)
# ---
# name: test_aircon_set_hvac_mode[off-OFF]
_Call(
tuple(
3,
'MANUAL',
None,
None,
'AIR_CONDITIONING',
'OFF',
),
dict({
}),
)
# ---
# name: test_heater_set_temperature
_Call(
tuple(
1,
'NEXT_TIME_BLOCK',
22.0,
None,
'HEATING',
'ON',
'HEAT',
None,
None,
None,
None,
None,
),
dict({
}),
)
# ---

View File

@ -1,5 +1,19 @@
"""The sensor tests for the tado platform."""
from unittest.mock import patch
from PyTado.interface.api.my_tado import TadoZone
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
DOMAIN as CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_TEMPERATURE,
HVACMode,
)
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE
from homeassistant.core import HomeAssistant
from .util import async_init_integration
@ -121,3 +135,104 @@ async def test_smartac_with_fanlevel_vertical_and_horizontal_swing(
# Only test for a subset of attributes in case
# HA changes the implementation and a new one appears
assert all(item in state.attributes.items() for item in expected_attributes.items())
async def test_heater_set_temperature(
hass: HomeAssistant, snapshot: SnapshotAssertion
) -> None:
"""Test the set temperature of the heater."""
await async_init_integration(hass)
with (
patch(
"homeassistant.components.tado.PyTado.interface.api.Tado.set_zone_overlay"
) as mock_set_state,
patch(
"homeassistant.components.tado.PyTado.interface.api.Tado.get_zone_state",
return_value={"setting": {"temperature": {"celsius": 22.0}}},
),
):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: "climate.baseboard_heater", ATTR_TEMPERATURE: 22.0},
blocking=True,
)
mock_set_state.assert_called_once()
snapshot.assert_match(mock_set_state.call_args)
@pytest.mark.parametrize(
("hvac_mode", "set_hvac_mode"),
[
(HVACMode.HEAT, "HEAT"),
(HVACMode.DRY, "DRY"),
(HVACMode.FAN_ONLY, "FAN"),
(HVACMode.COOL, "COOL"),
(HVACMode.OFF, "OFF"),
],
)
async def test_aircon_set_hvac_mode(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
hvac_mode: HVACMode,
set_hvac_mode: str,
) -> None:
"""Test the set hvac mode of the air conditioning."""
await async_init_integration(hass)
with (
patch(
"homeassistant.components.tado.__init__.PyTado.interface.api.Tado.set_zone_overlay"
) as mock_set_state,
patch(
"homeassistant.components.tado.__init__.PyTado.interface.api.Tado.get_zone_state",
return_value=TadoZone(
zone_id=1,
current_temp=18.7,
connection=None,
current_temp_timestamp="2025-01-02T12:51:52.802Z",
current_humidity=45.1,
current_humidity_timestamp="2025-01-02T12:51:52.802Z",
is_away=False,
current_hvac_action="IDLE",
current_fan_speed=None,
current_fan_level=None,
current_hvac_mode=set_hvac_mode,
current_swing_mode="OFF",
current_vertical_swing_mode="OFF",
current_horizontal_swing_mode="OFF",
target_temp=16.0,
available=True,
power="ON",
link="ONLINE",
ac_power_timestamp=None,
heating_power_timestamp="2025-01-02T13:01:11.758Z",
ac_power=None,
heating_power=None,
heating_power_percentage=0.0,
tado_mode="HOME",
overlay_termination_type="MANUAL",
overlay_termination_timestamp=None,
default_overlay_termination_type="MANUAL",
default_overlay_termination_duration=None,
preparation=False,
open_window=False,
open_window_detected=False,
open_window_attr={},
precision=0.1,
),
),
):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: "climate.air_conditioning", ATTR_HVAC_MODE: hvac_mode},
blocking=True,
)
mock_set_state.assert_called_once()
snapshot.assert_match(mock_set_state.call_args)

View File

@ -1,45 +1,94 @@
"""Helper method tests."""
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from homeassistant.components.tado import TadoConnector
from PyTado.interface import Tado
import pytest
from homeassistant.components.tado import TadoDataUpdateCoordinator
from homeassistant.components.tado.const import (
CONST_OVERLAY_MANUAL,
CONST_OVERLAY_TADO_DEFAULT,
CONST_OVERLAY_TADO_MODE,
CONST_OVERLAY_TIMER,
DOMAIN,
)
from homeassistant.components.tado.helper import decide_duration, decide_overlay_mode
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
def dummy_tado_connector(hass: HomeAssistant, fallback) -> TadoConnector:
@pytest.fixture
def entry(request: pytest.FixtureRequest) -> MockConfigEntry:
"""Fixture for ConfigEntry with optional fallback."""
fallback = (
request.param if hasattr(request, "param") else CONST_OVERLAY_TADO_DEFAULT
)
return MockConfigEntry(
version=1,
minor_version=1,
domain=DOMAIN,
title="Tado",
data={
CONF_USERNAME: "test-username",
CONF_PASSWORD: "test-password",
},
options={
"fallback": fallback,
},
)
@pytest.fixture
def tado() -> Tado:
"""Fixture for Tado instance."""
with patch(
"homeassistant.components.tado.PyTado.interface.api.Tado.set_zone_overlay"
) as mock_set_zone_overlay:
instance = MagicMock(spec=Tado)
instance.set_zone_overlay = mock_set_zone_overlay
yield instance
def dummy_tado_connector(
hass: HomeAssistant, entry: ConfigEntry, tado: Tado
) -> TadoDataUpdateCoordinator:
"""Return dummy tado connector."""
return TadoConnector(hass, username="dummy", password="dummy", fallback=fallback)
return TadoDataUpdateCoordinator(hass, entry, tado)
async def test_overlay_mode_duration_set(hass: HomeAssistant) -> None:
@pytest.mark.parametrize("entry", [CONST_OVERLAY_TADO_MODE], indirect=True)
async def test_overlay_mode_duration_set(
hass: HomeAssistant, entry: ConfigEntry, tado: Tado
) -> None:
"""Test overlay method selection when duration is set."""
tado = dummy_tado_connector(hass=hass, fallback=CONST_OVERLAY_TADO_MODE)
overlay_mode = decide_overlay_mode(tado=tado, duration=3600, zone_id=1)
tado = dummy_tado_connector(hass=hass, entry=entry, tado=tado)
overlay_mode = decide_overlay_mode(coordinator=tado, duration=3600, zone_id=1)
# Must select TIMER overlay
assert overlay_mode == CONST_OVERLAY_TIMER
async def test_overlay_mode_next_time_block_fallback(hass: HomeAssistant) -> None:
@pytest.mark.parametrize("entry", [CONST_OVERLAY_TADO_MODE], indirect=True)
async def test_overlay_mode_next_time_block_fallback(
hass: HomeAssistant, entry: ConfigEntry, tado: Tado
) -> None:
"""Test overlay method selection when duration is not set."""
integration_fallback = CONST_OVERLAY_TADO_MODE
tado = dummy_tado_connector(hass=hass, fallback=integration_fallback)
overlay_mode = decide_overlay_mode(tado=tado, duration=None, zone_id=1)
tado = dummy_tado_connector(hass=hass, entry=entry, tado=tado)
overlay_mode = decide_overlay_mode(coordinator=tado, duration=None, zone_id=1)
# Must fallback to integration wide setting
assert overlay_mode == integration_fallback
assert overlay_mode == CONST_OVERLAY_TADO_MODE
async def test_overlay_mode_tado_default_fallback(hass: HomeAssistant) -> None:
@pytest.mark.parametrize("entry", [CONST_OVERLAY_TADO_DEFAULT], indirect=True)
async def test_overlay_mode_tado_default_fallback(
hass: HomeAssistant, entry: ConfigEntry, tado: Tado
) -> None:
"""Test overlay method selection when tado default is selected."""
integration_fallback = CONST_OVERLAY_TADO_DEFAULT
zone_fallback = CONST_OVERLAY_MANUAL
tado = dummy_tado_connector(hass=hass, fallback=integration_fallback)
tado = dummy_tado_connector(hass=hass, entry=entry, tado=tado)
class MockZoneData:
def __init__(self) -> None:
@ -49,28 +98,40 @@ async def test_overlay_mode_tado_default_fallback(hass: HomeAssistant) -> None:
zone_data = {"zone": {zone_id: MockZoneData()}}
with patch.dict(tado.data, zone_data):
overlay_mode = decide_overlay_mode(tado=tado, duration=None, zone_id=zone_id)
overlay_mode = decide_overlay_mode(
coordinator=tado, duration=None, zone_id=zone_id
)
# Must fallback to zone setting
assert overlay_mode == zone_fallback
async def test_duration_enabled_without_tado_default(hass: HomeAssistant) -> None:
@pytest.mark.parametrize("entry", [CONST_OVERLAY_MANUAL], indirect=True)
async def test_duration_enabled_without_tado_default(
hass: HomeAssistant, entry: ConfigEntry, tado: Tado
) -> None:
"""Test duration decide method when overlay is timer and duration is set."""
overlay = CONST_OVERLAY_TIMER
expected_duration = 600
tado = dummy_tado_connector(hass=hass, fallback=CONST_OVERLAY_MANUAL)
tado = dummy_tado_connector(hass=hass, entry=entry, tado=tado)
duration = decide_duration(
tado=tado, duration=expected_duration, overlay_mode=overlay, zone_id=0
coordinator=tado, duration=expected_duration, overlay_mode=overlay, zone_id=0
)
# Should return the same duration value
assert duration == expected_duration
async def test_duration_enabled_with_tado_default(hass: HomeAssistant) -> None:
@pytest.mark.parametrize("entry", [CONST_OVERLAY_TIMER], indirect=True)
async def test_duration_enabled_with_tado_default(
hass: HomeAssistant, entry: ConfigEntry, tado: Tado
) -> None:
"""Test overlay method selection when ended up with timer overlay and None duration."""
zone_fallback = CONST_OVERLAY_TIMER
expected_duration = 45000
tado = dummy_tado_connector(hass=hass, fallback=zone_fallback)
tado = dummy_tado_connector(
hass=hass,
entry=entry,
tado=tado,
)
class MockZoneData:
def __init__(self) -> None:
@ -81,7 +142,7 @@ async def test_duration_enabled_with_tado_default(hass: HomeAssistant) -> None:
zone_data = {"zone": {zone_id: MockZoneData()}}
with patch.dict(tado.data, zone_data):
duration = decide_duration(
tado=tado, duration=None, zone_id=zone_id, overlay_mode=zone_fallback
coordinator=tado, duration=None, zone_id=zone_id, overlay_mode=zone_fallback
)
# Must fallback to zone timer setting
assert duration == expected_duration

View File

@ -80,7 +80,7 @@ async def test_add_meter_readings_exception(
blocking=True,
)
assert "Could not set meter reading" in str(exc)
assert "Error setting Tado meter reading: Error" in str(exc.value)
async def test_add_meter_readings_invalid(

View File

@ -188,3 +188,8 @@ async def async_init_integration(
if not skip_setup:
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
# For a first refresh
await entry.runtime_data.coordinator.async_refresh()
await entry.runtime_data.mobile_coordinator.async_refresh()
await hass.async_block_till_done()