diff --git a/homeassistant/components/tado/.translations/en.json b/homeassistant/components/tado/.translations/en.json new file mode 100644 index 00000000000..9336d140923 --- /dev/null +++ b/homeassistant/components/tado/.translations/en.json @@ -0,0 +1,35 @@ +{ + "config" : { + "abort" : { + "already_configured" : "Device is already configured" + }, + "step" : { + "user" : { + "data" : { + "password" : "Password", + "username" : "Username" + }, + "title" : "Connect to your Tado account" + } + }, + "error" : { + "unknown" : "Unexpected error", + "no_homes" : "There are no homes linked to this tado account.", + "invalid_auth" : "Invalid authentication", + "cannot_connect" : "Failed to connect, please try again" + }, + "title" : "Tado" + }, + "options" : { + "title" : "Tado", + "step" : { + "init" : { + "description" : "Fallback mode will switch to Smart Schedule at next schedule switch after manually adjusting a zone.", + "data" : { + "fallback" : "Enable fallback mode." + }, + "title" : "Adjust Tado options." + } + } + } +} diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 1dba5f5f29e..0f4e7e1c418 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -1,25 +1,34 @@ """Support for the (unofficial) Tado API.""" +import asyncio from datetime import timedelta import logging from PyTado.interface import Tado from requests import RequestException +import requests.exceptions import voluptuous as vol from homeassistant.components.climate.const import PRESET_AWAY, PRESET_HOME +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.event import async_track_time_interval from homeassistant.util import Throttle -from .const import CONF_FALLBACK, DATA +from .const import ( + CONF_FALLBACK, + DATA, + DOMAIN, + SIGNAL_TADO_UPDATE_RECEIVED, + UPDATE_LISTENER, + UPDATE_TRACK, +) _LOGGER = logging.getLogger(__name__) -DOMAIN = "tado" - -SIGNAL_TADO_UPDATE_RECEIVED = "tado_update_received_{}_{}" TADO_COMPONENTS = ["sensor", "climate", "water_heater"] @@ -43,45 +52,106 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass, config): - """Set up of the Tado component.""" - acc_list = config[DOMAIN] +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Tado component.""" - api_data_list = [] + hass.data.setdefault(DOMAIN, {}) - for acc in acc_list: - username = acc[CONF_USERNAME] - password = acc[CONF_PASSWORD] - fallback = acc[CONF_FALLBACK] + if DOMAIN not in config: + return True - tadoconnector = TadoConnector(hass, username, password, fallback) - if not tadoconnector.setup(): - continue - - # Do first update - tadoconnector.update() - - api_data_list.append(tadoconnector) - # Poll for updates in the background - hass.helpers.event.track_time_interval( - # we're using here tadoconnector as a parameter of lambda - # to capture actual value instead of closuring of latest value - lambda now, tc=tadoconnector: tc.update(), - SCAN_INTERVAL, - ) - - hass.data[DOMAIN] = {} - hass.data[DOMAIN][DATA] = api_data_list - - # Load components - for component in TADO_COMPONENTS: - load_platform( - hass, component, DOMAIN, {}, config, + for conf in config[DOMAIN]: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf, + ) ) return True +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Tado from a config entry.""" + + _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, True) + + tadoconnector = TadoConnector(hass, username, password, fallback) + + 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 ConfigEntryNotReady + 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 + + # Do first update + await hass.async_add_executor_job(tadoconnector.update) + + # Poll for updates in the background + update_track = async_track_time_interval( + hass, lambda now: tadoconnector.update(), SCAN_INTERVAL, + ) + + update_listener = entry.add_update_listener(_async_update_listener) + + hass.data[DOMAIN][entry.entry_id] = { + DATA: tadoconnector, + UPDATE_TRACK: update_track, + UPDATE_LISTENER: update_listener, + } + + for component in TADO_COMPONENTS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +@callback +def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: ConfigEntry): + options = dict(entry.options) + if CONF_FALLBACK not in options: + options[CONF_FALLBACK] = entry.data.get(CONF_FALLBACK, True) + hass.config_entries.async_update_entry(entry, options=options) + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in TADO_COMPONENTS + ] + ) + ) + + hass.data[DOMAIN][entry.entry_id][UPDATE_TRACK]() + hass.data[DOMAIN][entry.entry_id][UPDATE_LISTENER]() + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + class TadoConnector: """An object to store the Tado data.""" @@ -108,19 +178,12 @@ class TadoConnector: def setup(self): """Connect to Tado and fetch the zones.""" - try: - self.tado = Tado(self._username, self._password) - except (RuntimeError, RequestException) as exc: - _LOGGER.error("Unable to connect: %s", exc) - return False - + self.tado = Tado(self._username, self._password) self.tado.setDebugging(True) - # Load zones and devices self.zones = self.tado.getZones() self.devices = self.tado.getMe()["homes"] self.device_id = self.devices[0]["id"] - return True @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index 2c6e49f3273..8b2da9e632d 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -14,11 +14,11 @@ from homeassistant.components.climate.const import ( SUPPORT_SWING_MODE, SUPPORT_TARGET_TEMPERATURE, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, TEMP_CELSIUS -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import DOMAIN, SIGNAL_TADO_UPDATE_RECEIVED from .const import ( CONST_FAN_AUTO, CONST_FAN_OFF, @@ -30,9 +30,11 @@ from .const import ( CONST_OVERLAY_MANUAL, CONST_OVERLAY_TADO_MODE, DATA, + DOMAIN, HA_TO_TADO_FAN_MODE_MAP, HA_TO_TADO_HVAC_MODE_MAP, ORDERED_KNOWN_TADO_MODES, + SIGNAL_TADO_UPDATE_RECEIVED, SUPPORT_PRESET, TADO_HVAC_ACTION_TO_HA_HVAC_ACTION, TADO_MODES_WITH_NO_TEMP_SETTING, @@ -42,30 +44,37 @@ from .const import ( TYPE_AIR_CONDITIONING, TYPE_HEATING, ) +from .entity import TadoZoneEntity _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities +): """Set up the Tado climate platform.""" - if discovery_info is None: - return - api_list = hass.data[DOMAIN][DATA] - entities = [] - - for tado in api_list: - for zone in tado.zones: - if zone["type"] in [TYPE_HEATING, TYPE_AIR_CONDITIONING]: - entity = create_climate_entity(tado, zone["name"], zone["id"]) - if entity: - entities.append(entity) + tado = hass.data[DOMAIN][entry.entry_id][DATA] + entities = await hass.async_add_executor_job(_generate_entities, tado) if entities: - add_entities(entities, True) + async_add_entities(entities, True) -def create_climate_entity(tado, name: str, zone_id: int): +def _generate_entities(tado): + """Create all climate entities.""" + entities = [] + for zone in tado.zones: + if zone["type"] in [TYPE_HEATING, TYPE_AIR_CONDITIONING]: + entity = create_climate_entity( + tado, zone["name"], zone["id"], zone["devices"][0] + ) + if entity: + entities.append(entity) + return entities + + +def create_climate_entity(tado, name: str, zone_id: int, zone: dict): """Create a Tado climate entity.""" capabilities = tado.get_capabilities(zone_id) _LOGGER.debug("Capabilities for zone %s: %s", zone_id, capabilities) @@ -148,11 +157,12 @@ def create_climate_entity(tado, name: str, zone_id: int): supported_hvac_modes, supported_fan_modes, support_flags, + zone, ) return entity -class TadoClimate(ClimateDevice): +class TadoClimate(TadoZoneEntity, ClimateDevice): """Representation of a Tado climate entity.""" def __init__( @@ -170,11 +180,12 @@ class TadoClimate(ClimateDevice): supported_hvac_modes, supported_fan_modes, support_flags, + device_info, ): """Initialize of Tado climate entity.""" self._tado = tado + super().__init__(zone_name, device_info, tado.device_id, zone_id) - self.zone_name = zone_name self.zone_id = zone_id self.zone_type = zone_type self._unique_id = f"{zone_type} {zone_id} {tado.device_id}" @@ -206,6 +217,7 @@ class TadoClimate(ClimateDevice): self._undo_dispatcher = None self._tado_zone_data = None + self._async_update_zone_data() async def async_will_remove_from_hass(self): @@ -237,11 +249,6 @@ class TadoClimate(ClimateDevice): """Return the unique id.""" return self._unique_id - @property - def should_poll(self) -> bool: - """Do not poll.""" - return False - @property def current_humidity(self): """Return the current humidity.""" diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py new file mode 100644 index 00000000000..c14b4284cf3 --- /dev/null +++ b/homeassistant/components/tado/config_flow.py @@ -0,0 +1,148 @@ +"""Config flow for Tado integration.""" +import logging + +from PyTado.interface import Tado +import requests.exceptions +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback + +from .const import CONF_FALLBACK, UNIQUE_ID +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + + try: + tado = await hass.async_add_executor_job( + Tado, data[CONF_USERNAME], data[CONF_PASSWORD] + ) + tado_me = await hass.async_add_executor_job(tado.getMe) + except KeyError: + raise InvalidAuth + except RuntimeError: + raise CannotConnect + except requests.exceptions.HTTPError as ex: + if ex.response.status_code > 400 and ex.response.status_code < 500: + raise InvalidAuth + raise CannotConnect + + if "homes" not in tado_me or len(tado_me["homes"]) == 0: + raise NoHomes + + home = tado_me["homes"][0] + unique_id = str(home["id"]) + name = home["name"] + + return {"title": name, UNIQUE_ID: unique_id} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Tado.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + validated = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except NoHomes: + errors["base"] = "no_homes" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if "base" not in errors: + await self.async_set_unique_id(validated[UNIQUE_ID]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=validated["title"], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_homekit(self, homekit_info): + """Handle HomeKit discovery.""" + if self._async_current_entries(): + # We can see tado on the network to tell them to configure + # it, but since the device will not give up the account it is + # bound to and there can be multiple tado devices on a single + # account, we avoid showing the device as discovered once + # they already have one configured as they can always + # add a new one via "+" + return self.async_abort(reason="already_configured") + return await self.async_step_user() + + async def async_step_import(self, user_input): + """Handle import.""" + if self._username_already_configured(user_input): + return self.async_abort(reason="already_configured") + return await self.async_step_user(user_input) + + def _username_already_configured(self, user_input): + """See if we already have a username matching user input configured.""" + existing_username = { + entry.data[CONF_USERNAME] for entry in self._async_current_entries() + } + return user_input[CONF_USERNAME] in existing_username + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for tado.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + data_schema = vol.Schema( + { + vol.Required( + CONF_FALLBACK, default=self.config_entry.options.get(CONF_FALLBACK) + ): bool, + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class NoHomes(exceptions.HomeAssistantError): + """Error to indicate the account has no homes.""" diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py index ab965de035a..67f71b97430 100644 --- a/homeassistant/components/tado/const.py +++ b/homeassistant/components/tado/const.py @@ -46,6 +46,7 @@ TADO_HVAC_ACTION_TO_HA_HVAC_ACTION = { # Configuration CONF_FALLBACK = "fallback" DATA = "data" +UPDATE_TRACK = "update_track" # Types TYPE_AIR_CONDITIONING = "AIR_CONDITIONING" @@ -135,3 +136,14 @@ SUPPORT_PRESET = [PRESET_AWAY, PRESET_HOME] TADO_SWING_OFF = "OFF" TADO_SWING_ON = "ON" + +DOMAIN = "tado" + +SIGNAL_TADO_UPDATE_RECEIVED = "tado_update_received_{}_{}" +UNIQUE_ID = "unique_id" + +DEFAULT_NAME = "Tado" + +TADO_BRIDGE = "Tado Bridge" + +UPDATE_LISTENER = "update_listener" diff --git a/homeassistant/components/tado/entity.py b/homeassistant/components/tado/entity.py new file mode 100644 index 00000000000..97a3e3bdc39 --- /dev/null +++ b/homeassistant/components/tado/entity.py @@ -0,0 +1,37 @@ +"""Base class for August entity.""" + +import logging + +from homeassistant.helpers.entity import Entity + +from .const import DEFAULT_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class TadoZoneEntity(Entity): + """Base implementation for tado device.""" + + def __init__(self, zone_name, device_info, device_id, zone_id): + """Initialize an August device.""" + super().__init__() + self._device_zone_id = f"{device_id}_{zone_id}" + self._device_info = device_info + self.zone_name = zone_name + + @property + def device_info(self): + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self._device_zone_id)}, + "name": self.zone_name, + "manufacturer": DEFAULT_NAME, + "sw_version": self._device_info["currentFwVersion"], + "model": self._device_info["deviceType"], + "via_device": (DOMAIN, self._device_info["serialNo"]), + } + + @property + def should_poll(self): + """Do not poll.""" + return False diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index 741612b6b4b..f739459402a 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -3,5 +3,9 @@ "name": "Tado", "documentation": "https://www.home-assistant.io/integrations/tado", "requirements": ["python-tado==0.6.0"], - "codeowners": ["@michaelarnauts", "@bdraco"] + "codeowners": ["@michaelarnauts", "@bdraco"], + "config_flow": true, + "homekit": { + "models": ["tado", "AC02"] + } } diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index fea81dcb586..1d409a8a4e9 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -1,13 +1,23 @@ """Support for Tado sensors for each zone.""" import logging +from homeassistant.config_entries import ConfigEntry from homeassistant.const import TEMP_CELSIUS, UNIT_PERCENTAGE -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from . import DATA, DOMAIN, SIGNAL_TADO_UPDATE_RECEIVED -from .const import TYPE_AIR_CONDITIONING, TYPE_HEATING, TYPE_HOT_WATER +from .const import ( + DATA, + DEFAULT_NAME, + DOMAIN, + SIGNAL_TADO_UPDATE_RECEIVED, + TADO_BRIDGE, + TYPE_AIR_CONDITIONING, + TYPE_HEATING, + TYPE_HOT_WATER, +) +from .entity import TadoZoneEntity _LOGGER = logging.getLogger(__name__) @@ -39,50 +49,53 @@ ZONE_SENSORS = { DEVICE_SENSORS = ["tado bridge status"] -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the sensor platform.""" - api_list = hass.data[DOMAIN][DATA] +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities +): + """Set up the Tado sensor platform.""" + tado = hass.data[DOMAIN][entry.entry_id][DATA] + # Create zone sensors + zones = tado.zones + devices = tado.devices entities = [] - for tado in api_list: - # Create zone sensors - zones = tado.zones - devices = tado.devices + for zone in zones: + zone_type = zone["type"] + if zone_type not in ZONE_SENSORS: + _LOGGER.warning("Unknown zone type skipped: %s", zone_type) + continue - for zone in zones: - zone_type = zone["type"] - if zone_type not in ZONE_SENSORS: - _LOGGER.warning("Unknown zone type skipped: %s", zone_type) - continue + entities.extend( + [ + TadoZoneSensor( + tado, zone["name"], zone["id"], variable, zone["devices"][0] + ) + for variable in ZONE_SENSORS[zone_type] + ] + ) - entities.extend( - [ - TadoZoneSensor(tado, zone["name"], zone["id"], variable) - for variable in ZONE_SENSORS[zone_type] - ] - ) + # Create device sensors + for device in devices: + entities.extend( + [ + TadoDeviceSensor(tado, device["name"], device["id"], variable, device) + for variable in DEVICE_SENSORS + ] + ) - # Create device sensors - for device in devices: - entities.extend( - [ - TadoDeviceSensor(tado, device["name"], device["id"], variable) - for variable in DEVICE_SENSORS - ] - ) - - add_entities(entities, True) + if entities: + async_add_entities(entities, True) -class TadoZoneSensor(Entity): +class TadoZoneSensor(TadoZoneEntity, Entity): """Representation of a tado Sensor.""" - def __init__(self, tado, zone_name, zone_id, zone_variable): + def __init__(self, tado, zone_name, zone_id, zone_variable, device_info): """Initialize of the Tado Sensor.""" self._tado = tado + super().__init__(zone_name, device_info, tado.device_id, zone_id) - self.zone_name = zone_name self.zone_id = zone_id self.zone_variable = zone_variable @@ -148,11 +161,6 @@ class TadoZoneSensor(Entity): if self.zone_variable == "humidity": return "mdi:water-percent" - @property - def should_poll(self): - """Do not poll.""" - return False - @callback def _async_update_callback(self): """Update and write state.""" @@ -223,10 +231,11 @@ class TadoZoneSensor(Entity): class TadoDeviceSensor(Entity): """Representation of a tado Sensor.""" - def __init__(self, tado, device_name, device_id, device_variable): + def __init__(self, tado, device_name, device_id, device_variable, device_info): """Initialize of the Tado Sensor.""" self._tado = tado + self._device_info = device_info self.device_name = device_name self.device_id = device_id self.device_variable = device_variable @@ -289,3 +298,13 @@ class TadoDeviceSensor(Entity): if self.device_variable == "tado bridge status": self._state = data.get("connectionState", {}).get("value", False) + + @property + def device_info(self): + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self.device_id)}, + "name": self.device_name, + "manufacturer": DEFAULT_NAME, + "model": TADO_BRIDGE, + } diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json new file mode 100644 index 00000000000..697dadb4c7d --- /dev/null +++ b/homeassistant/components/tado/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username" + }, + "title": "Connect to your Tado account" + } + }, + "error": { + "unknown": "Unexpected error", + "no_homes": "There are no homes linked to this tado account.", + "invalid_auth": "Invalid authentication", + "cannot_connect": "Failed to connect, please try again" + }, + "title": "Tado" + }, + "options": { + "title": "Tado", + "step": { + "init": { + "description": "Fallback mode will switch to Smart Schedule at next schedule switch after manually adjusting a zone.", + "data": { + "fallback": "Enable fallback mode." + }, + "title": "Adjust Tado options." + } + } + } +} diff --git a/homeassistant/components/tado/water_heater.py b/homeassistant/components/tado/water_heater.py index 51ff2ede57d..ac9005738c4 100644 --- a/homeassistant/components/tado/water_heater.py +++ b/homeassistant/components/tado/water_heater.py @@ -6,11 +6,11 @@ from homeassistant.components.water_heater import ( SUPPORT_TARGET_TEMPERATURE, WaterHeaterDevice, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import DOMAIN, SIGNAL_TADO_UPDATE_RECEIVED from .const import ( CONST_HVAC_HEAT, CONST_MODE_AUTO, @@ -21,8 +21,11 @@ from .const import ( CONST_OVERLAY_TADO_MODE, CONST_OVERLAY_TIMER, DATA, + DOMAIN, + SIGNAL_TADO_UPDATE_RECEIVED, TYPE_HOT_WATER, ) +from .entity import TadoZoneEntity _LOGGER = logging.getLogger(__name__) @@ -44,25 +47,31 @@ WATER_HEATER_MAP_TADO = { SUPPORT_FLAGS_HEATER = SUPPORT_OPERATION_MODE -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities +): """Set up the Tado water heater platform.""" - if discovery_info is None: - return - api_list = hass.data[DOMAIN][DATA] - entities = [] - - for tado in api_list: - for zone in tado.zones: - if zone["type"] == TYPE_HOT_WATER: - entity = create_water_heater_entity(tado, zone["name"], zone["id"]) - entities.append(entity) + tado = hass.data[DOMAIN][entry.entry_id][DATA] + entities = await hass.async_add_executor_job(_generate_entities, tado) if entities: - add_entities(entities, True) + async_add_entities(entities, True) -def create_water_heater_entity(tado, name: str, zone_id: int): +def _generate_entities(tado): + """Create all water heater entities.""" + entities = [] + + for zone in tado.zones: + if zone["type"] == TYPE_HOT_WATER: + entity = create_water_heater_entity(tado, zone["name"], zone["id"], zone) + entities.append(entity) + + return entities + + +def create_water_heater_entity(tado, name: str, zone_id: int, zone: str): """Create a Tado water heater device.""" capabilities = tado.get_capabilities(zone_id) @@ -77,13 +86,19 @@ def create_water_heater_entity(tado, name: str, zone_id: int): max_temp = None entity = TadoWaterHeater( - tado, name, zone_id, supports_temperature_control, min_temp, max_temp + tado, + name, + zone_id, + supports_temperature_control, + min_temp, + max_temp, + zone["devices"][0], ) return entity -class TadoWaterHeater(WaterHeaterDevice): +class TadoWaterHeater(TadoZoneEntity, WaterHeaterDevice): """Representation of a Tado water heater.""" def __init__( @@ -94,11 +109,13 @@ class TadoWaterHeater(WaterHeaterDevice): supports_temperature_control, min_temp, max_temp, + device_info, ): """Initialize of Tado water heater entity.""" - self._tado = tado - self.zone_name = zone_name + self._tado = tado + super().__init__(zone_name, device_info, tado.device_id, zone_id) + self.zone_id = zone_id self._unique_id = f"{zone_id} {tado.device_id}" @@ -149,11 +166,6 @@ class TadoWaterHeater(WaterHeaterDevice): """Return the unique id.""" return self._unique_id - @property - def should_poll(self) -> bool: - """Do not poll.""" - return False - @property def current_operation(self): """Return current readable operation mode.""" diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 0e78ddecc4b..a7dbd486089 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -113,6 +113,7 @@ FLOWS = [ "spotify", "starline", "synology_dsm", + "tado", "tellduslive", "tesla", "toon", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index f5519ea903b..d6e4965c235 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -47,6 +47,7 @@ ZEROCONF = { HOMEKIT = { "819LMB": "myq", + "AC02": "tado", "BSB002": "hue", "Healty Home Coach": "netatmo", "LIFX": "lifx", @@ -55,5 +56,6 @@ HOMEKIT = { "Rachio": "rachio", "TRADFRI": "tradfri", "Welcome": "netatmo", - "Wemo": "wemo" + "Wemo": "wemo", + "tado": "tado" } diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py new file mode 100644 index 00000000000..fb9156d96d9 --- /dev/null +++ b/tests/components/tado/test_config_flow.py @@ -0,0 +1,167 @@ +"""Test the Tado config flow.""" +from asynctest import MagicMock, patch +import requests + +from homeassistant import config_entries, setup +from homeassistant.components.tado.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + + +def _get_mock_tado_api(getMe=None): + mock_tado = MagicMock() + if isinstance(getMe, Exception): + type(mock_tado).getMe = MagicMock(side_effect=getMe) + else: + type(mock_tado).getMe = MagicMock(return_value=getMe) + return mock_tado + + +async def test_form(hass): + """Test we can setup though the user path.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + mock_tado_api = _get_mock_tado_api(getMe={"homes": [{"id": 1, "name": "myhome"}]}) + + with patch( + "homeassistant.components.tado.config_flow.Tado", return_value=mock_tado_api, + ), patch( + "homeassistant.components.tado.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.tado.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-username", "password": "test-password"}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "myhome" + assert result2["data"] == { + "username": "test-username", + "password": "test-password", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import(hass): + """Test we can import.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + mock_tado_api = _get_mock_tado_api(getMe={"homes": [{"id": 1, "name": "myhome"}]}) + + with patch( + "homeassistant.components.tado.config_flow.Tado", return_value=mock_tado_api, + ), patch( + "homeassistant.components.tado.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.tado.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={"username": "test-username", "password": "test-password"}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == "myhome" + assert result["data"] == { + "username": "test-username", + "password": "test-password", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + response_mock = MagicMock() + type(response_mock).status_code = 401 + mock_tado_api = _get_mock_tado_api(getMe=requests.HTTPError(response=response_mock)) + + with patch( + "homeassistant.components.tado.config_flow.Tado", return_value=mock_tado_api, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-username", "password": "test-password"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + response_mock = MagicMock() + type(response_mock).status_code = 500 + mock_tado_api = _get_mock_tado_api(getMe=requests.HTTPError(response=response_mock)) + + with patch( + "homeassistant.components.tado.config_flow.Tado", return_value=mock_tado_api, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-username", "password": "test-password"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_no_homes(hass): + """Test we handle no homes error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_tado_api = _get_mock_tado_api(getMe={"homes": []}) + + with patch( + "homeassistant.components.tado.config_flow.Tado", return_value=mock_tado_api, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-username", "password": "test-password"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "no_homes"} + + +async def test_form_homekit(hass): + """Test that we abort from homekit if tado is already setup.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "homekit"} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_USERNAME: "mock", CONF_PASSWORD: "mock"} + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "homekit"} + ) + assert result["type"] == "abort" diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py index 1b7e1ad888e..5c060e76eec 100644 --- a/tests/components/tado/util.py +++ b/tests/components/tado/util.py @@ -5,9 +5,8 @@ import requests_mock from homeassistant.components.tado import DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from tests.common import load_fixture +from tests.common import MockConfigEntry, load_fixture async def async_init_integration( @@ -93,8 +92,11 @@ async def async_init_integration( "https://my.tado.com/api/v2/homes/1/zones/1/state", text=load_fixture(zone_1_state_fixture), ) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_USERNAME: "mock", CONF_PASSWORD: "mock"} + ) + entry.add_to_hass(hass) + if not skip_setup: - assert await async_setup_component( - hass, DOMAIN, {DOMAIN: {CONF_USERNAME: "mock", CONF_PASSWORD: "mock"}} - ) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done()