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 from datetime import timedelta
import logging import logging
import requests.exceptions import PyTado
import PyTado.exceptions
from PyTado.interface import Tado
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant, callback 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 import config_validation as cv
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from .const import ( from .const import (
@ -21,11 +22,9 @@ from .const import (
CONST_OVERLAY_TADO_OPTIONS, CONST_OVERLAY_TADO_OPTIONS,
DOMAIN, DOMAIN,
) )
from .coordinator import TadoDataUpdateCoordinator, TadoMobileDeviceUpdateCoordinator
from .models import TadoData
from .services import setup_services from .services import setup_services
from .tado_connector import TadoConnector
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [ PLATFORMS = [
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
@ -41,16 +40,17 @@ SCAN_MOBILE_DEVICE_INTERVAL = timedelta(seconds=30)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Tado.""" """Set up Tado."""
setup_services(hass) setup_services(hass)
return True return True
type TadoConfigEntry = ConfigEntry[TadoConnector] type TadoConfigEntry = ConfigEntry[TadoData]
async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool: 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) _async_import_options_from_data_if_missing(hass, entry)
username = entry.data[CONF_USERNAME] _LOGGER.debug("Setting up Tado connection")
password = entry.data[CONF_PASSWORD]
fallback = entry.options.get(CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT)
tadoconnector = TadoConnector(hass, username, password, fallback)
try: try:
await hass.async_add_executor_job(tadoconnector.setup) tado = await hass.async_add_executor_job(
except KeyError: Tado,
_LOGGER.error("Failed to login to tado") entry.data[CONF_USERNAME],
return False entry.data[CONF_PASSWORD],
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,
) )
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( coordinator = TadoDataUpdateCoordinator(hass, entry, tado)
async_track_time_interval( await coordinator.async_config_entry_first_refresh()
hass,
lambda now: tadoconnector.update_mobile_devices(),
SCAN_MOBILE_DEVICE_INTERVAL,
)
)
entry.async_on_unload(entry.add_update_listener(_async_update_listener)) mobile_coordinator = TadoMobileDeviceUpdateCoordinator(hass, entry, tado)
await mobile_coordinator.async_config_entry_first_refresh()
entry.runtime_data = tadoconnector
entry.runtime_data = TadoData(coordinator, mobile_coordinator)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
return True 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) 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.""" """Handle options update."""
await hass.config_entries.async_reload(entry.entry_id) await hass.config_entries.async_reload(entry.entry_id)

View File

@ -13,21 +13,19 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription, BinarySensorEntityDescription,
) )
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
from . import TadoConfigEntry from . import TadoConfigEntry
from .const import ( from .const import (
SIGNAL_TADO_UPDATE_RECEIVED,
TYPE_AIR_CONDITIONING, TYPE_AIR_CONDITIONING,
TYPE_BATTERY, TYPE_BATTERY,
TYPE_HEATING, TYPE_HEATING,
TYPE_HOT_WATER, TYPE_HOT_WATER,
TYPE_POWER, TYPE_POWER,
) )
from .coordinator import TadoDataUpdateCoordinator
from .entity import TadoDeviceEntity, TadoZoneEntity from .entity import TadoDeviceEntity, TadoZoneEntity
from .tado_connector import TadoConnector
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -121,7 +119,7 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up the Tado sensor platform.""" """Set up the Tado sensor platform."""
tado = entry.runtime_data tado = entry.runtime_data.coordinator
devices = tado.devices devices = tado.devices
zones = tado.zones zones = tado.zones
entities: list[BinarySensorEntity] = [] entities: list[BinarySensorEntity] = []
@ -164,43 +162,23 @@ class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity):
def __init__( def __init__(
self, self,
tado: TadoConnector, coordinator: TadoDataUpdateCoordinator,
device_info: dict[str, Any], device_info: dict[str, Any],
entity_description: TadoBinarySensorEntityDescription, entity_description: TadoBinarySensorEntityDescription,
) -> None: ) -> None:
"""Initialize of the Tado Sensor.""" """Initialize of the Tado Sensor."""
self.entity_description = entity_description self.entity_description = entity_description
self._tado = tado super().__init__(device_info, coordinator)
super().__init__(device_info)
self._attr_unique_id = ( 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 @callback
def _async_update_callback(self) -> None: def _handle_coordinator_update(self) -> None:
"""Update and write state.""" """Handle updated data from the coordinator."""
self._async_update_device_data()
self.async_write_ha_state()
@callback
def _async_update_device_data(self) -> None:
"""Handle update callbacks."""
try: try:
self._device_info = self._tado.data["device"][self.device_id] self._device_info = self.coordinator.data["device"][self.device_id]
except KeyError: except KeyError:
return return
@ -209,6 +187,7 @@ class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity):
self._attr_extra_state_attributes = self.entity_description.attributes_fn( self._attr_extra_state_attributes = self.entity_description.attributes_fn(
self._device_info self._device_info
) )
super()._handle_coordinator_update()
class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity):
@ -218,42 +197,24 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity):
def __init__( def __init__(
self, self,
tado: TadoConnector, coordinator: TadoDataUpdateCoordinator,
zone_name: str, zone_name: str,
zone_id: int, zone_id: int,
entity_description: TadoBinarySensorEntityDescription, entity_description: TadoBinarySensorEntityDescription,
) -> None: ) -> None:
"""Initialize of the Tado Sensor.""" """Initialize of the Tado Sensor."""
self.entity_description = entity_description self.entity_description = entity_description
self._tado = tado super().__init__(zone_name, coordinator.home_id, zone_id, coordinator)
super().__init__(zone_name, tado.home_id, zone_id)
self._attr_unique_id = f"{entity_description.key} {zone_id} {tado.home_id}" self._attr_unique_id = (
f"{entity_description.key} {zone_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, "zone", self.zone_id
),
self._async_update_callback,
) )
)
self._async_update_zone_data()
@callback @callback
def _async_update_callback(self) -> None: def _handle_coordinator_update(self) -> None:
"""Update and write state.""" """Handle updated data from the coordinator."""
self._async_update_zone_data()
self.async_write_ha_state()
@callback
def _async_update_zone_data(self) -> None:
"""Handle update callbacks."""
try: try:
tado_zone_data = self._tado.data["zone"][self.zone_id] tado_zone_data = self.coordinator.data["zone"][self.zone_id]
except KeyError: except KeyError:
return return
@ -262,3 +223,4 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity):
self._attr_extra_state_attributes = self.entity_description.attributes_fn( self._attr_extra_state_attributes = self.entity_description.attributes_fn(
tado_zone_data 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.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_platform 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.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.typing import VolDictType
from . import TadoConfigEntry, TadoConnector from . import TadoConfigEntry
from .const import ( from .const import (
CONST_EXCLUSIVE_OVERLAY_GROUP, CONST_EXCLUSIVE_OVERLAY_GROUP,
CONST_FAN_AUTO, CONST_FAN_AUTO,
@ -50,7 +49,6 @@ from .const import (
HA_TO_TADO_HVAC_MODE_MAP, HA_TO_TADO_HVAC_MODE_MAP,
ORDERED_KNOWN_TADO_MODES, ORDERED_KNOWN_TADO_MODES,
PRESET_AUTO, PRESET_AUTO,
SIGNAL_TADO_UPDATE_RECEIVED,
SUPPORT_PRESET_AUTO, SUPPORT_PRESET_AUTO,
SUPPORT_PRESET_MANUAL, SUPPORT_PRESET_MANUAL,
TADO_DEFAULT_MAX_TEMP, TADO_DEFAULT_MAX_TEMP,
@ -73,6 +71,7 @@ from .const import (
TYPE_AIR_CONDITIONING, TYPE_AIR_CONDITIONING,
TYPE_HEATING, TYPE_HEATING,
) )
from .coordinator import TadoDataUpdateCoordinator
from .entity import TadoZoneEntity from .entity import TadoZoneEntity
from .helper import decide_duration, decide_overlay_mode, generate_supported_fanmodes from .helper import decide_duration, decide_overlay_mode, generate_supported_fanmodes
@ -105,8 +104,8 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up the Tado climate platform.""" """Set up the Tado climate platform."""
tado = entry.runtime_data tado = entry.runtime_data.coordinator
entities = await hass.async_add_executor_job(_generate_entities, tado) entities = await _generate_entities(tado)
platform = entity_platform.async_get_current_platform() platform = entity_platform.async_get_current_platform()
@ -125,12 +124,12 @@ async def async_setup_entry(
async_add_entities(entities, True) async_add_entities(entities, True)
def _generate_entities(tado: TadoConnector) -> list[TadoClimate]: async def _generate_entities(tado: TadoDataUpdateCoordinator) -> list[TadoClimate]:
"""Create all climate entities.""" """Create all climate entities."""
entities = [] entities = []
for zone in tado.zones: for zone in tado.zones:
if zone["type"] in [TYPE_HEATING, TYPE_AIR_CONDITIONING]: 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] tado, zone["name"], zone["id"], zone["devices"][0]
) )
if entity: if entity:
@ -138,11 +137,11 @@ def _generate_entities(tado: TadoConnector) -> list[TadoClimate]:
return entities return entities
def create_climate_entity( async def create_climate_entity(
tado: TadoConnector, name: str, zone_id: int, device_info: dict tado: TadoDataUpdateCoordinator, name: str, zone_id: int, device_info: dict
) -> TadoClimate | None: ) -> TadoClimate | None:
"""Create a Tado climate entity.""" """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) _LOGGER.debug("Capabilities for zone %s: %s", zone_id, capabilities)
zone_type = capabilities["type"] zone_type = capabilities["type"]
@ -243,6 +242,8 @@ def create_climate_entity(
cool_max_temp = float(cool_temperatures["celsius"]["max"]) cool_max_temp = float(cool_temperatures["celsius"]["max"])
cool_step = cool_temperatures["celsius"].get("step", PRECISION_TENTHS) cool_step = cool_temperatures["celsius"].get("step", PRECISION_TENTHS)
auto_geofencing_supported = await tado.get_auto_geofencing_supported()
return TadoClimate( return TadoClimate(
tado, tado,
name, name,
@ -251,6 +252,8 @@ def create_climate_entity(
supported_hvac_modes, supported_hvac_modes,
support_flags, support_flags,
device_info, device_info,
capabilities,
auto_geofencing_supported,
heat_min_temp, heat_min_temp,
heat_max_temp, heat_max_temp,
heat_step, heat_step,
@ -272,13 +275,15 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
def __init__( def __init__(
self, self,
tado: TadoConnector, coordinator: TadoDataUpdateCoordinator,
zone_name: str, zone_name: str,
zone_id: int, zone_id: int,
zone_type: str, zone_type: str,
supported_hvac_modes: list[HVACMode], supported_hvac_modes: list[HVACMode],
support_flags: ClimateEntityFeature, support_flags: ClimateEntityFeature,
device_info: dict[str, str], device_info: dict[str, str],
capabilities: dict[str, str],
auto_geofencing_supported: bool,
heat_min_temp: float | None = None, heat_min_temp: float | None = None,
heat_max_temp: float | None = None, heat_max_temp: float | None = None,
heat_step: float | None = None, heat_step: float | None = None,
@ -289,13 +294,13 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
supported_swing_modes: list[str] | None = None, supported_swing_modes: list[str] | None = None,
) -> None: ) -> None:
"""Initialize of Tado climate entity.""" """Initialize of Tado climate entity."""
self._tado = tado self._tado = coordinator
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.zone_id = zone_id
self.zone_type = zone_type 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_info = device_info
self._device_id = self._device_info["shortSerialNo"] 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_vertical_swing = TADO_SWING_OFF
self._current_tado_horizontal_swing = TADO_SWING_OFF self._current_tado_horizontal_swing = TADO_SWING_OFF
capabilities = tado.get_capabilities(zone_id)
self._current_tado_capabilities = capabilities self._current_tado_capabilities = capabilities
self._auto_geofencing_supported = auto_geofencing_supported
self._tado_zone_data: PyTado.TadoZone = {} self._tado_zone_data: PyTado.TadoZone = {}
self._tado_geofence_data: dict[str, str] | None = None self._tado_geofence_data: dict[str, str] | None = None
self._tado_zone_temp_offset: dict[str, Any] = {} self._tado_zone_temp_offset: dict[str, Any] = {}
self._async_update_home_data()
self._async_update_zone_data() self._async_update_zone_data()
async def async_added_to_hass(self) -> None: @callback
"""Register for sensor updates.""" def _handle_coordinator_update(self) -> None:
self.async_on_remove( """Handle updated data from the coordinator."""
async_dispatcher_connect( self._async_update_zone_data()
self.hass, super()._handle_coordinator_update()
SIGNAL_TADO_UPDATE_RECEIVED.format(self._tado.home_id, "home", "data"),
self._async_update_home_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
) )
self.async_on_remove( @callback
async_dispatcher_connect( def _async_update_zone_callback(self) -> None:
self.hass, """Load tado data and update state."""
SIGNAL_TADO_UPDATE_RECEIVED.format( self._async_update_zone_data()
self._tado.home_id, "zone", self.zone_id
),
self._async_update_zone_callback,
)
)
@property @property
def current_humidity(self) -> int | None: def current_humidity(self) -> int | None:
@ -401,12 +431,13 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
return FAN_AUTO return FAN_AUTO
return None 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.""" """Turn fan on/off."""
if self._is_valid_setting_for_hvac_mode(TADO_FANSPEED_SETTING): 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): 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 @property
def preset_mode(self) -> str: def preset_mode(self) -> str:
@ -425,13 +456,14 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
@property @property
def preset_modes(self) -> list[str]: def preset_modes(self) -> list[str]:
"""Return a list of available preset modes.""" """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_AUTO
return SUPPORT_PRESET_MANUAL 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.""" """Set new preset mode."""
self._tado.set_presence(preset_mode) await self._tado.set_presence(preset_mode)
await self.coordinator.async_request_refresh()
@property @property
def target_temperature_step(self) -> float | None: def target_temperature_step(self) -> float | None:
@ -449,7 +481,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
# the device is switching states # the device is switching states
return self._tado_zone_data.target_temp or self._tado_zone_data.current_temp return self._tado_zone_data.target_temp or self._tado_zone_data.current_temp
def set_timer( async def set_timer(
self, self,
temperature: float, temperature: float,
time_period: int | None = None, time_period: int | None = None,
@ -457,14 +489,15 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
): ):
"""Set the timer on the entity, and temperature if supported.""" """Set the timer on the entity, and temperature if supported."""
self._control_hvac( await self._control_hvac(
hvac_mode=CONST_MODE_HEAT, hvac_mode=CONST_MODE_HEAT,
target_temp=temperature, target_temp=temperature,
duration=time_period, duration=time_period,
overlay_mode=requested_overlay, 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.""" """Set offset on the entity."""
_LOGGER.debug( _LOGGER.debug(
@ -474,8 +507,9 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
) )
self._tado.set_temperature_offset(self._device_id, offset) 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.""" """Set new target temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
return return
@ -485,15 +519,21 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
CONST_MODE_AUTO, CONST_MODE_AUTO,
CONST_MODE_SMART_SCHEDULE, CONST_MODE_SMART_SCHEDULE,
): ):
self._control_hvac(target_temp=temperature) await self._control_hvac(target_temp=temperature)
await self.coordinator.async_request_refresh()
return return
new_hvac_mode = CONST_MODE_COOL if self._ac_device else CONST_MODE_HEAT 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.""" """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 @property
def available(self) -> bool: def available(self) -> bool:
@ -559,7 +599,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
) )
return state_attr 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.""" """Set swing modes for the device."""
vertical_swing = None vertical_swing = None
horizontal_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): if self._is_valid_setting_for_hvac_mode(TADO_HORIZONTAL_SWING_SETTING):
horizontal_swing = TADO_SWING_ON horizontal_swing = TADO_SWING_ON
self._control_hvac( await self._control_hvac(
swing_mode=swing, swing_mode=swing,
vertical_swing=vertical_swing, vertical_swing=vertical_swing,
horizontal_swing=horizontal_swing, horizontal_swing=horizontal_swing,
) )
await self.coordinator.async_request_refresh()
@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()
def _normalize_target_temp_for_hvac_mode(self) -> None: def _normalize_target_temp_for_hvac_mode(self) -> None:
def adjust_temp(min_temp, max_temp) -> float | 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: elif self._current_tado_hvac_mode == CONST_MODE_HEAT:
self._target_temp = adjust_temp(self._heat_min_temp, self._heat_max_temp) self._target_temp = adjust_temp(self._heat_min_temp, self._heat_max_temp)
def _control_hvac( async def _control_hvac(
self, self,
hvac_mode: str | None = None, hvac_mode: str | None = None,
target_temp: float | None = None, target_temp: float | None = None,
@ -712,7 +702,9 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
_LOGGER.debug( _LOGGER.debug(
"Switching to OFF for zone %s (%d)", self.zone_name, self.zone_id "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 return
if self._current_tado_hvac_mode == CONST_MODE_SMART_SCHEDULE: if self._current_tado_hvac_mode == CONST_MODE_SMART_SCHEDULE:
@ -721,17 +713,17 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
self.zone_name, self.zone_name,
self.zone_id, self.zone_id,
) )
self._tado.reset_zone_overlay(self.zone_id) await self._tado.reset_zone_overlay(self.zone_id)
return return
overlay_mode = decide_overlay_mode( overlay_mode = decide_overlay_mode(
tado=self._tado, coordinator=self._tado,
duration=duration, duration=duration,
overlay_mode=overlay_mode, overlay_mode=overlay_mode,
zone_id=self.zone_id, zone_id=self.zone_id,
) )
duration = decide_duration( duration = decide_duration(
tado=self._tado, coordinator=self._tado,
duration=duration, duration=duration,
zone_id=self.zone_id, zone_id=self.zone_id,
overlay_mode=overlay_mode, overlay_mode=overlay_mode,
@ -785,7 +777,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
): ):
swing = self._current_tado_swing_mode swing = self._current_tado_swing_mode
self._tado.set_zone_overlay( await self._tado.set_zone_overlay(
zone_id=self.zone_id, zone_id=self.zone_id,
overlay_mode=overlay_mode, # What to do when the period ends overlay_mode=overlay_mode, # What to do when the period ends
temperature=temperature_to_send, temperature=temperature_to_send,
@ -800,18 +792,23 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
) )
def _is_valid_setting_for_hvac_mode(self, setting: str) -> bool: def _is_valid_setting_for_hvac_mode(self, setting: str) -> bool:
return ( """Determine if a setting is valid for the current HVAC mode."""
self._current_tado_capabilities.get(self._current_tado_hvac_mode, {}).get( capabilities: str | dict[str, str] = self._current_tado_capabilities.get(
setting self._current_tado_hvac_mode, {}
)
is not None
) )
if isinstance(capabilities, dict):
return capabilities.get(setting) is not None
return False
def _is_current_setting_supported_by_current_hvac_mode( def _is_current_setting_supported_by_current_hvac_mode(
self, setting: str, current_state: str | None self, setting: str, current_state: str | None
) -> bool: ) -> bool:
if self._is_valid_setting_for_hvac_mode(setting): """Determine if the current setting is supported by the current HVAC mode."""
return current_state in self._current_tado_capabilities[ capabilities: str | dict[str, str] = self._current_tado_capabilities.get(
self._current_tado_hvac_mode self._current_tado_hvac_mode, {}
].get(setting, []) )
if isinstance(capabilities, dict) and self._is_valid_setting_for_hvac_mode(
setting
):
return current_state in capabilities.get(setting, [])
return False 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.const import STATE_HOME, STATE_NOT_HOME
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er 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.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from . import TadoConfigEntry from . import TadoConfigEntry
from .const import DOMAIN, SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED from .const import DOMAIN
from .tado_connector import TadoConnector from .coordinator import TadoMobileDeviceUpdateCoordinator
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -28,7 +31,7 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up the Tado device scannery entity.""" """Set up the Tado device scannery entity."""
_LOGGER.debug("Setting up Tado device scanner entity") _LOGGER.debug("Setting up Tado device scanner entity")
tado = entry.runtime_data tado = entry.runtime_data.mobile_coordinator
tracked: set = set() tracked: set = set()
# Fix non-string unique_id for device trackers # Fix non-string unique_id for device trackers
@ -49,58 +52,56 @@ async def async_setup_entry(
update_devices() update_devices()
entry.async_on_unload(
async_dispatcher_connect(
hass,
SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED.format(tado.home_id),
update_devices,
)
)
@callback @callback
def add_tracked_entities( def add_tracked_entities(
hass: HomeAssistant, hass: HomeAssistant,
tado: TadoConnector, coordinator: TadoMobileDeviceUpdateCoordinator,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
tracked: set[str], tracked: set[str],
) -> None: ) -> None:
"""Add new tracker entities from Tado.""" """Add new tracker entities from Tado."""
_LOGGER.debug("Fetching Tado devices from API for (newly) tracked entities") _LOGGER.debug("Fetching Tado devices from API for (newly) tracked entities")
new_tracked = [] 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: if device_key in tracked:
continue continue
_LOGGER.debug( _LOGGER.debug(
"Adding Tado device %s with deviceID %s", device["name"], device_key "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) tracked.add(device_key)
async_add_entities(new_tracked) async_add_entities(new_tracked)
class TadoDeviceTrackerEntity(TrackerEntity): class TadoDeviceTrackerEntity(CoordinatorEntity[DataUpdateCoordinator], TrackerEntity):
"""A Tado Device Tracker entity.""" """A Tado Device Tracker entity."""
_attr_should_poll = False
_attr_available = False _attr_available = False
def __init__( def __init__(
self, self,
device_id: str, device_id: str,
device_name: str, device_name: str,
tado: TadoConnector, coordinator: TadoMobileDeviceUpdateCoordinator,
) -> None: ) -> None:
"""Initialize a Tado Device Tracker entity.""" """Initialize a Tado Device Tracker entity."""
super().__init__() super().__init__(coordinator)
self._attr_unique_id = str(device_id) self._attr_unique_id = str(device_id)
self._device_id = device_id self._device_id = device_id
self._device_name = device_name self._device_name = device_name
self._tado = tado
self._active = False self._active = False
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.update_state()
super()._handle_coordinator_update()
@callback @callback
def update_state(self) -> None: def update_state(self) -> None:
"""Update the Tado device.""" """Update the Tado device."""
@ -109,7 +110,7 @@ class TadoDeviceTrackerEntity(TrackerEntity):
self._device_name, self._device_name,
self._device_id, 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 self._attr_available = False
_LOGGER.debug( _LOGGER.debug(
@ -129,25 +130,6 @@ class TadoDeviceTrackerEntity(TrackerEntity):
else: else:
_LOGGER.debug("Tado device %s is not at home", device["name"]) _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 @property
def name(self) -> str: def name(self) -> str:
"""Return the name of the device.""" """Return the name of the device."""

View File

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

View File

@ -5,26 +5,27 @@ from .const import (
CONST_OVERLAY_TADO_MODE, CONST_OVERLAY_TADO_MODE,
CONST_OVERLAY_TIMER, CONST_OVERLAY_TIMER,
) )
from .tado_connector import TadoConnector from .coordinator import TadoDataUpdateCoordinator
def decide_overlay_mode( def decide_overlay_mode(
tado: TadoConnector, coordinator: TadoDataUpdateCoordinator,
duration: int | None, duration: int | None,
zone_id: int, zone_id: int,
overlay_mode: str | None = None, overlay_mode: str | None = None,
) -> str: ) -> str:
"""Return correct overlay mode based on the action and defaults.""" """Return correct overlay mode based on the action and defaults."""
# If user gave duration then overlay mode needs to be timer # If user gave duration then overlay mode needs to be timer
if duration: if duration:
return CONST_OVERLAY_TIMER return CONST_OVERLAY_TIMER
# If no duration or timer set to fallback setting # If no duration or timer set to fallback setting
if overlay_mode is None: 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 default is Tado default then look it up
if overlay_mode == CONST_OVERLAY_TADO_DEFAULT: if overlay_mode == CONST_OVERLAY_TADO_DEFAULT:
overlay_mode = ( 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 or CONST_OVERLAY_TADO_MODE
) )
@ -32,18 +33,19 @@ def decide_overlay_mode(
def decide_duration( def decide_duration(
tado: TadoConnector, coordinator: TadoDataUpdateCoordinator,
duration: int | None, duration: int | None,
zone_id: int, zone_id: int,
overlay_mode: str | None = None, overlay_mode: str | None = None,
) -> None | int: ) -> None | int:
"""Return correct duration based on the selected overlay mode/duration and tado config.""" """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 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: if overlay_mode == CONST_OVERLAY_TIMER and duration is None:
duration = ( duration = (
int(tado.data["zone"][zone_id].default_overlay_termination_duration) int(coordinator.data["zone"][zone_id].default_overlay_termination_duration)
if tado.data["zone"][zone_id].default_overlay_termination_duration if coordinator.data["zone"][zone_id].default_overlay_termination_duration
is not None is not None
else 3600 else 3600
) )
@ -53,6 +55,7 @@ def decide_duration(
def generate_supported_fanmodes(tado_to_ha_mapping: dict[str, str], options: list[str]): def generate_supported_fanmodes(tado_to_ha_mapping: dict[str, str], options: list[str]):
"""Return correct list of fan modes or None.""" """Return correct list of fan modes or None."""
supported_fanmodes = [ supported_fanmodes = [
tado_to_ha_mapping.get(option) tado_to_ha_mapping.get(option)
for option in options 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.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
@ -24,13 +23,12 @@ from .const import (
CONDITIONS_MAP, CONDITIONS_MAP,
SENSOR_DATA_CATEGORY_GEOFENCE, SENSOR_DATA_CATEGORY_GEOFENCE,
SENSOR_DATA_CATEGORY_WEATHER, SENSOR_DATA_CATEGORY_WEATHER,
SIGNAL_TADO_UPDATE_RECEIVED,
TYPE_AIR_CONDITIONING, TYPE_AIR_CONDITIONING,
TYPE_HEATING, TYPE_HEATING,
TYPE_HOT_WATER, TYPE_HOT_WATER,
) )
from .coordinator import TadoDataUpdateCoordinator
from .entity import TadoHomeEntity, TadoZoneEntity from .entity import TadoHomeEntity, TadoZoneEntity
from .tado_connector import TadoConnector
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -197,7 +195,7 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up the Tado sensor platform.""" """Set up the Tado sensor platform."""
tado = entry.runtime_data tado = entry.runtime_data.coordinator
zones = tado.zones zones = tado.zones
entities: list[SensorEntity] = [] entities: list[SensorEntity] = []
@ -232,39 +230,22 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity):
entity_description: TadoSensorEntityDescription entity_description: TadoSensorEntityDescription
def __init__( def __init__(
self, tado: TadoConnector, entity_description: TadoSensorEntityDescription self,
coordinator: TadoDataUpdateCoordinator,
entity_description: TadoSensorEntityDescription,
) -> None: ) -> None:
"""Initialize of the Tado Sensor.""" """Initialize of the Tado Sensor."""
self.entity_description = entity_description self.entity_description = entity_description
super().__init__(tado) super().__init__(coordinator)
self._tado = tado
self._attr_unique_id = f"{entity_description.key} {tado.home_id}" self._attr_unique_id = f"{entity_description.key} {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, "home", "data"),
self._async_update_callback,
)
)
self._async_update_home_data()
@callback @callback
def _async_update_callback(self) -> None: def _handle_coordinator_update(self) -> None:
"""Update and write state.""" """Handle updated data from the coordinator."""
self._async_update_home_data()
self.async_write_ha_state()
@callback
def _async_update_home_data(self) -> None:
"""Handle update callbacks."""
try: try:
tado_weather_data = self._tado.data["weather"] tado_weather_data = self.coordinator.data["weather"]
tado_geofence_data = self._tado.data["geofence"] tado_geofence_data = self.coordinator.data["geofence"]
except KeyError: except KeyError:
return return
@ -278,6 +259,7 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity):
self._attr_extra_state_attributes = self.entity_description.attributes_fn( self._attr_extra_state_attributes = self.entity_description.attributes_fn(
tado_sensor_data tado_sensor_data
) )
super()._handle_coordinator_update()
class TadoZoneSensor(TadoZoneEntity, SensorEntity): class TadoZoneSensor(TadoZoneEntity, SensorEntity):
@ -287,43 +269,24 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity):
def __init__( def __init__(
self, self,
tado: TadoConnector, coordinator: TadoDataUpdateCoordinator,
zone_name: str, zone_name: str,
zone_id: int, zone_id: int,
entity_description: TadoSensorEntityDescription, entity_description: TadoSensorEntityDescription,
) -> None: ) -> None:
"""Initialize of the Tado Sensor.""" """Initialize of the Tado Sensor."""
self.entity_description = entity_description self.entity_description = entity_description
self._tado = tado super().__init__(zone_name, coordinator.home_id, zone_id, coordinator)
super().__init__(zone_name, tado.home_id, zone_id)
self._attr_unique_id = f"{entity_description.key} {zone_id} {tado.home_id}" self._attr_unique_id = (
f"{entity_description.key} {zone_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, "zone", self.zone_id
),
self._async_update_callback,
) )
)
self._async_update_zone_data()
@callback @callback
def _async_update_callback(self) -> None: def _handle_coordinator_update(self) -> None:
"""Update and write state.""" """Handle updated data from the coordinator."""
self._async_update_zone_data()
self.async_write_ha_state()
@callback
def _async_update_zone_data(self) -> None:
"""Handle update callbacks."""
try: try:
tado_zone_data = self._tado.data["zone"][self.zone_id] tado_zone_data = self.coordinator.data["zone"][self.zone_id]
except KeyError: except KeyError:
return return
@ -332,3 +295,4 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity):
self._attr_extra_state_attributes = self.entity_description.attributes_fn( self._attr_extra_state_attributes = self.entity_description.attributes_fn(
tado_zone_data tado_zone_data
) )
super()._handle_coordinator_update()

View File

@ -43,11 +43,8 @@ def setup_services(hass: HomeAssistant) -> None:
if entry is None: if entry is None:
raise ServiceValidationError("Config entry not found") raise ServiceValidationError("Config entry not found")
tadoconnector = entry.runtime_data coordinator = entry.runtime_data.coordinator
response: dict = await coordinator.set_meter_reading(call.data[CONF_READING])
response: dict = await hass.async_add_executor_job(
tadoconnector.set_meter_reading, call.data[CONF_READING]
)
if ATTR_MESSAGE in response: if ATTR_MESSAGE in response:
raise HomeAssistantError(response[ATTR_MESSAGE]) 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.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_platform 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.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.typing import VolDictType
@ -26,13 +25,12 @@ from .const import (
CONST_OVERLAY_MANUAL, CONST_OVERLAY_MANUAL,
CONST_OVERLAY_TADO_MODE, CONST_OVERLAY_TADO_MODE,
CONST_OVERLAY_TIMER, CONST_OVERLAY_TIMER,
SIGNAL_TADO_UPDATE_RECEIVED,
TYPE_HOT_WATER, TYPE_HOT_WATER,
) )
from .coordinator import TadoDataUpdateCoordinator
from .entity import TadoZoneEntity from .entity import TadoZoneEntity
from .helper import decide_duration, decide_overlay_mode from .helper import decide_duration, decide_overlay_mode
from .repairs import manage_water_heater_fallback_issue from .repairs import manage_water_heater_fallback_issue
from .tado_connector import TadoConnector
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -67,8 +65,9 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up the Tado water heater platform.""" """Set up the Tado water heater platform."""
tado = entry.runtime_data data = entry.runtime_data
entities = await hass.async_add_executor_job(_generate_entities, tado) coordinator = data.coordinator
entities = await _generate_entities(coordinator)
platform = entity_platform.async_get_current_platform() platform = entity_platform.async_get_current_platform()
@ -83,27 +82,29 @@ async def async_setup_entry(
manage_water_heater_fallback_issue( manage_water_heater_fallback_issue(
hass=hass, hass=hass,
water_heater_names=[e.zone_name for e in entities], 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.""" """Create all water heater entities."""
entities = [] entities = []
for zone in tado.zones: for zone in coordinator.zones:
if zone["type"] == TYPE_HOT_WATER: if zone["type"] == TYPE_HOT_WATER:
entity = create_water_heater_entity( entity = await create_water_heater_entity(
tado, zone["name"], zone["id"], str(zone["name"]) coordinator, zone["name"], zone["id"], str(zone["name"])
) )
entities.append(entity) entities.append(entity)
return entities 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.""" """Create a Tado water heater device."""
capabilities = tado.get_capabilities(zone_id) capabilities = await coordinator.get_capabilities(zone_id)
supports_temperature_control = capabilities["canSetTemperature"] 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 max_temp = None
return TadoWaterHeater( return TadoWaterHeater(
tado, coordinator,
name, name,
zone_id, zone_id,
supports_temperature_control, supports_temperature_control,
@ -134,7 +135,7 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity):
def __init__( def __init__(
self, self,
tado: TadoConnector, coordinator: TadoDataUpdateCoordinator,
zone_name: str, zone_name: str,
zone_id: int, zone_id: int,
supports_temperature_control: bool, supports_temperature_control: bool,
@ -142,11 +143,10 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity):
max_temp, max_temp,
) -> None: ) -> None:
"""Initialize of Tado water heater entity.""" """Initialize of Tado water heater entity."""
self._tado = tado super().__init__(zone_name, coordinator.home_id, zone_id, coordinator)
super().__init__(zone_name, tado.home_id, zone_id)
self.zone_id = zone_id 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 self._device_is_active = False
@ -164,19 +164,14 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity):
self._overlay_mode = CONST_MODE_SMART_SCHEDULE self._overlay_mode = CONST_MODE_SMART_SCHEDULE
self._tado_zone_data: Any = None 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() 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 @property
def current_operation(self) -> str | None: def current_operation(self) -> str | None:
"""Return current readable operation mode.""" """Return current readable operation mode."""
@ -202,7 +197,7 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity):
"""Return the maximum temperature.""" """Return the maximum temperature."""
return self._max_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.""" """Set new operation mode."""
mode = None mode = None
@ -213,18 +208,20 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity):
elif operation_mode == MODE_HEAT: elif operation_mode == MODE_HEAT:
mode = CONST_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.""" """Set the timer on the entity, and temperature if supported."""
if not self._supports_temperature_control and temperature is not None: if not self._supports_temperature_control and temperature is not None:
temperature = None temperature = None
self._control_heater( await self._control_heater(
hvac_mode=CONST_MODE_HEAT, target_temp=temperature, duration=time_period 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.""" """Set new target temperature."""
temperature = kwargs.get(ATTR_TEMPERATURE) temperature = kwargs.get(ATTR_TEMPERATURE)
if not self._supports_temperature_control or temperature is None: if not self._supports_temperature_control or temperature is None:
@ -235,10 +232,11 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity):
CONST_MODE_AUTO, CONST_MODE_AUTO,
CONST_MODE_SMART_SCHEDULE, CONST_MODE_SMART_SCHEDULE,
): ):
self._control_heater(target_temp=temperature) await self._control_heater(target_temp=temperature)
return 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 @callback
def _async_update_callback(self) -> None: def _async_update_callback(self) -> None:
@ -250,10 +248,10 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity):
def _async_update_data(self) -> None: def _async_update_data(self) -> None:
"""Load tado data.""" """Load tado data."""
_LOGGER.debug("Updating water_heater platform for zone %d", self.zone_id) _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 self._current_tado_hvac_mode = self._tado_zone_data.current_hvac_mode
def _control_heater( async def _control_heater(
self, self,
hvac_mode: str | None = None, hvac_mode: str | None = None,
target_temp: float | None = None, target_temp: float | None = None,
@ -276,23 +274,26 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity):
self.zone_name, self.zone_name,
self.zone_id, 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 return
if self._current_tado_hvac_mode == CONST_MODE_OFF: if self._current_tado_hvac_mode == CONST_MODE_OFF:
_LOGGER.debug( _LOGGER.debug(
"Switching to OFF for zone %s (%d)", self.zone_name, self.zone_id "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 return
overlay_mode = decide_overlay_mode( overlay_mode = decide_overlay_mode(
tado=self._tado, coordinator=self.coordinator,
duration=duration, duration=duration,
zone_id=self.zone_id, zone_id=self.zone_id,
) )
duration = decide_duration( duration = decide_duration(
tado=self._tado, coordinator=self.coordinator,
duration=duration, duration=duration,
zone_id=self.zone_id, zone_id=self.zone_id,
overlay_mode=overlay_mode, overlay_mode=overlay_mode,
@ -304,7 +305,7 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity):
self.zone_id, self.zone_id,
self._target_temp, self._target_temp,
) )
self._tado.set_zone_overlay( await self.coordinator.set_zone_overlay(
zone_id=self.zone_id, zone_id=self.zone_id,
overlay_mode=overlay_mode, overlay_mode=overlay_mode,
temperature=self._target_temp, 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.""" """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 homeassistant.core import HomeAssistant
from .util import async_init_integration 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 # Only test for a subset of attributes in case
# HA changes the implementation and a new one appears # HA changes the implementation and a new one appears
assert all(item in state.attributes.items() for item in expected_attributes.items()) 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.""" """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 ( from homeassistant.components.tado.const import (
CONST_OVERLAY_MANUAL, CONST_OVERLAY_MANUAL,
CONST_OVERLAY_TADO_DEFAULT, CONST_OVERLAY_TADO_DEFAULT,
CONST_OVERLAY_TADO_MODE, CONST_OVERLAY_TADO_MODE,
CONST_OVERLAY_TIMER, CONST_OVERLAY_TIMER,
DOMAIN,
) )
from homeassistant.components.tado.helper import decide_duration, decide_overlay_mode 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 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 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.""" """Test overlay method selection when duration is set."""
tado = dummy_tado_connector(hass=hass, fallback=CONST_OVERLAY_TADO_MODE) tado = dummy_tado_connector(hass=hass, entry=entry, tado=tado)
overlay_mode = decide_overlay_mode(tado=tado, duration=3600, zone_id=1) overlay_mode = decide_overlay_mode(coordinator=tado, duration=3600, zone_id=1)
# Must select TIMER overlay # Must select TIMER overlay
assert overlay_mode == CONST_OVERLAY_TIMER 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.""" """Test overlay method selection when duration is not set."""
integration_fallback = CONST_OVERLAY_TADO_MODE tado = dummy_tado_connector(hass=hass, entry=entry, tado=tado)
tado = dummy_tado_connector(hass=hass, fallback=integration_fallback) overlay_mode = decide_overlay_mode(coordinator=tado, duration=None, zone_id=1)
overlay_mode = decide_overlay_mode(tado=tado, duration=None, zone_id=1)
# Must fallback to integration wide setting # 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.""" """Test overlay method selection when tado default is selected."""
integration_fallback = CONST_OVERLAY_TADO_DEFAULT
zone_fallback = CONST_OVERLAY_MANUAL 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: class MockZoneData:
def __init__(self) -> None: 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()}} zone_data = {"zone": {zone_id: MockZoneData()}}
with patch.dict(tado.data, zone_data): 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 # Must fallback to zone setting
assert overlay_mode == zone_fallback 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.""" """Test duration decide method when overlay is timer and duration is set."""
overlay = CONST_OVERLAY_TIMER overlay = CONST_OVERLAY_TIMER
expected_duration = 600 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( 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 # Should return the same duration value
assert duration == expected_duration 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.""" """Test overlay method selection when ended up with timer overlay and None duration."""
zone_fallback = CONST_OVERLAY_TIMER zone_fallback = CONST_OVERLAY_TIMER
expected_duration = 45000 expected_duration = 45000
tado = dummy_tado_connector(hass=hass, fallback=zone_fallback) tado = dummy_tado_connector(
hass=hass,
entry=entry,
tado=tado,
)
class MockZoneData: class MockZoneData:
def __init__(self) -> None: 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()}} zone_data = {"zone": {zone_id: MockZoneData()}}
with patch.dict(tado.data, zone_data): with patch.dict(tado.data, zone_data):
duration = decide_duration( 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 # Must fallback to zone timer setting
assert duration == expected_duration assert duration == expected_duration

View File

@ -80,7 +80,7 @@ async def test_add_meter_readings_exception(
blocking=True, 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( async def test_add_meter_readings_invalid(

View File

@ -188,3 +188,8 @@ async def async_init_integration(
if not skip_setup: if not skip_setup:
await hass.config_entries.async_setup(entry.entry_id) await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done() 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()