diff --git a/.coveragerc b/.coveragerc index a4d6d0d201e..a8932f54a54 100644 --- a/.coveragerc +++ b/.coveragerc @@ -156,7 +156,12 @@ omit = homeassistant/components/ebox/sensor.py homeassistant/components/ebusd/* homeassistant/components/ecoal_boiler/* - homeassistant/components/ecobee/* + homeassistant/components/ecobee/__init__.py + homeassistant/components/ecobee/binary_sensor.py + homeassistant/components/ecobee/climate.py + homeassistant/components/ecobee/notify.py + homeassistant/components/ecobee/sensor.py + homeassistant/components/ecobee/weather.py homeassistant/components/econet/water_heater.py homeassistant/components/ecovacs/* homeassistant/components/eddystone_temperature/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 7e05cdf0b39..cb9180d717d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -73,6 +73,7 @@ homeassistant/components/digital_ocean/* @fabaff homeassistant/components/discogs/* @thibmaek homeassistant/components/doorbird/* @oblogic7 homeassistant/components/dweet/* @fabaff +homeassistant/components/ecobee/* @marthoc homeassistant/components/ecovacs/* @OverloadUT homeassistant/components/egardia/* @jeroenterheerdt homeassistant/components/eight_sleep/* @mezz64 diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index 4e9a4a3a4f4..f10e1b4bd69 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -72,26 +72,6 @@ set_swing_mode: swing_mode: description: New value of swing mode. -ecobee_set_fan_min_on_time: - description: Set the minimum fan on time. - fields: - entity_id: - description: Name(s) of entities to change. - example: 'climate.kitchen' - fan_min_on_time: - description: New value of fan min on time. - example: 5 - -ecobee_resume_program: - description: Resume the programmed schedule. - fields: - entity_id: - description: Name(s) of entities to change. - example: 'climate.kitchen' - resume_all: - description: Resume all events and return to the scheduled program. This default to false which removes only the top event. - example: true - mill_set_room_temperature: description: Set Mill room temperatures. fields: diff --git a/homeassistant/components/ecobee/.translations/en.json b/homeassistant/components/ecobee/.translations/en.json new file mode 100644 index 00000000000..9e7e9fed396 --- /dev/null +++ b/homeassistant/components/ecobee/.translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "title": "ecobee", + "step": { + "user": { + "title": "ecobee API key", + "description": "Please enter the API key obtained from ecobee.com.", + "data": {"api_key": "API Key"} + }, + "authorize": { + "title": "Authorize app on ecobee.com", + "description": "Please authorize this app at https://www.ecobee.com/consumerportal/index.html with pin code:\n\n{pin}\n\nThen, press Submit." + } + }, + "error": { + "pin_request_failed": "Error requesting PIN from ecobee; please verify API key is correct.", + "token_request_failed": "Error requesting tokens from ecobee; please try again." + }, + "abort": { + "one_instance_only": "This integration currently supports only one ecobee instance." + } + } +} diff --git a/homeassistant/components/ecobee/__init__.py b/homeassistant/components/ecobee/__init__.py index cb8b7436b51..eb65a7ed426 100644 --- a/homeassistant/components/ecobee/__init__.py +++ b/homeassistant/components/ecobee/__init__.py @@ -1,123 +1,130 @@ -"""Support for Ecobee devices.""" -import logging -import os +"""Support for ecobee.""" +import asyncio from datetime import timedelta - import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers import discovery +from pyecobee import Ecobee, ECOBEE_API_KEY, ECOBEE_REFRESH_TOKEN, ExpiredTokenError + +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_API_KEY +from homeassistant.helpers import config_validation as cv from homeassistant.util import Throttle -from homeassistant.util.json import save_json -_CONFIGURING = {} -_LOGGER = logging.getLogger(__name__) - -CONF_HOLD_TEMP = "hold_temp" - -DOMAIN = "ecobee" - -ECOBEE_CONFIG_FILE = "ecobee.conf" +from .const import ( + CONF_REFRESH_TOKEN, + DATA_ECOBEE_CONFIG, + DOMAIN, + ECOBEE_PLATFORMS, + _LOGGER, +) MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180) -NETWORK = None - CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Optional(CONF_API_KEY): cv.string, - vol.Optional(CONF_HOLD_TEMP, default=False): cv.boolean, - } - ) - }, - extra=vol.ALLOW_EXTRA, + {DOMAIN: vol.Schema({vol.Optional(CONF_API_KEY): cv.string})}, extra=vol.ALLOW_EXTRA ) -def request_configuration(network, hass, config): - """Request configuration steps from the user.""" - configurator = hass.components.configurator - if "ecobee" in _CONFIGURING: - configurator.notify_errors( - _CONFIGURING["ecobee"], "Failed to register, please try again." +async def async_setup(hass, config): + """ + Ecobee uses config flow for configuration. + + But, an "ecobee:" entry in configuration.yaml will trigger an import flow + if a config entry doesn't already exist. If ecobee.conf exists, the import + flow will attempt to import it and create a config entry, to assist users + migrating from the old ecobee component. Otherwise, the user will have to + continue setting up the integration via the config flow. + """ + hass.data[DATA_ECOBEE_CONFIG] = config.get(DOMAIN, {}) + + if not hass.config_entries.async_entries(DOMAIN) and hass.data[DATA_ECOBEE_CONFIG]: + # No config entry exists and configuration.yaml config exists, trigger the import flow. + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT} + ) ) - return - - def ecobee_configuration_callback(callback_data): - """Handle configuration callbacks.""" - network.request_tokens() - network.update() - setup_ecobee(hass, network, config) - - _CONFIGURING["ecobee"] = configurator.request_config( - "Ecobee", - ecobee_configuration_callback, - description=( - "Please authorize this app at https://www.ecobee.com/consumer" - "portal/index.html with pin code: " + network.pin - ), - description_image="/static/images/config_ecobee_thermostat.png", - submit_caption="I have authorized the app.", - ) + return True -def setup_ecobee(hass, network, config): - """Set up the Ecobee thermostat.""" - # If ecobee has a PIN then it needs to be configured. - if network.pin is not None: - request_configuration(network, hass, config) - return +async def async_setup_entry(hass, entry): + """Set up ecobee via a config entry.""" + api_key = entry.data[CONF_API_KEY] + refresh_token = entry.data[CONF_REFRESH_TOKEN] - if "ecobee" in _CONFIGURING: - configurator = hass.components.configurator - configurator.request_done(_CONFIGURING.pop("ecobee")) + data = EcobeeData(hass, entry, api_key=api_key, refresh_token=refresh_token) - hold_temp = config[DOMAIN].get(CONF_HOLD_TEMP) + if not await data.refresh(): + return False - discovery.load_platform(hass, "climate", DOMAIN, {"hold_temp": hold_temp}, config) - discovery.load_platform(hass, "sensor", DOMAIN, {}, config) - discovery.load_platform(hass, "binary_sensor", DOMAIN, {}, config) - discovery.load_platform(hass, "weather", DOMAIN, {}, config) + await data.update() + + if data.ecobee.thermostats is None: + _LOGGER.error("No ecobee devices found to set up") + return False + + hass.data[DOMAIN] = data + + for component in ECOBEE_PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True class EcobeeData: - """Get the latest data and update the states.""" + """ + Handle getting the latest data from ecobee.com so platforms can use it. - def __init__(self, config_file): - """Init the Ecobee data object.""" - from pyecobee import Ecobee + Also handle refreshing tokens and updating config entry with refreshed tokens. + """ - self.ecobee = Ecobee(config_file) + def __init__(self, hass, entry, api_key, refresh_token): + """Initialize the Ecobee data object.""" + self._hass = hass + self._entry = entry + self.ecobee = Ecobee( + config={ECOBEE_API_KEY: api_key, ECOBEE_REFRESH_TOKEN: refresh_token} + ) @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Get the latest data from pyecobee.""" - self.ecobee.update() - _LOGGER.debug("Ecobee data updated successfully") + async def update(self): + """Get the latest data from ecobee.com.""" + try: + await self._hass.async_add_executor_job(self.ecobee.update) + _LOGGER.debug("Updating ecobee") + except ExpiredTokenError: + _LOGGER.warning( + "Ecobee update failed; attempting to refresh expired tokens" + ) + await self.refresh() + + async def refresh(self) -> bool: + """Refresh ecobee tokens and update config entry.""" + _LOGGER.debug("Refreshing ecobee tokens and updating config entry") + if await self._hass.async_add_executor_job(self.ecobee.refresh_tokens): + self._hass.config_entries.async_update_entry( + self._entry, + data={ + CONF_API_KEY: self.ecobee.config[ECOBEE_API_KEY], + CONF_REFRESH_TOKEN: self.ecobee.config[ECOBEE_REFRESH_TOKEN], + }, + ) + return True + _LOGGER.error("Error updating ecobee tokens") + return False -def setup(hass, config): - """Set up the Ecobee. +async def async_unload_entry(hass, config_entry): + """Unload the config entry and platforms.""" + hass.data.pop(DOMAIN) - Will automatically load thermostat and sensor components to support - devices discovered on the network. - """ - global NETWORK + tasks = [] + for platform in ECOBEE_PLATFORMS: + tasks.append( + hass.config_entries.async_forward_entry_unload(config_entry, platform) + ) - if "ecobee" in _CONFIGURING: - return - - # Create ecobee.conf if it doesn't exist - if not os.path.isfile(hass.config.path(ECOBEE_CONFIG_FILE)): - jsonconfig = {"API_KEY": config[DOMAIN].get(CONF_API_KEY)} - save_json(hass.config.path(ECOBEE_CONFIG_FILE), jsonconfig) - - NETWORK = EcobeeData(hass.config.path(ECOBEE_CONFIG_FILE)) - - setup_ecobee(hass, NETWORK.ecobee, config) - - return True + return all(await asyncio.gather(*tasks)) diff --git a/homeassistant/components/ecobee/binary_sensor.py b/homeassistant/components/ecobee/binary_sensor.py index a3cd49ff458..8b7b819cfc7 100644 --- a/homeassistant/components/ecobee/binary_sensor.py +++ b/homeassistant/components/ecobee/binary_sensor.py @@ -1,15 +1,20 @@ """Support for Ecobee binary sensors.""" -from homeassistant.components import ecobee -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, + DEVICE_CLASS_OCCUPANCY, +) -ECOBEE_CONFIG_FILE = "ecobee.conf" +from .const import DOMAIN -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Ecobee sensors.""" - if discovery_info is None: - return - data = ecobee.NETWORK +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up ecobee binary sensors.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up ecobee binary (occupancy) sensors.""" + data = hass.data[DOMAIN] dev = list() for index in range(len(data.ecobee.thermostats)): for sensor in data.ecobee.get_remote_sensors(index): @@ -17,21 +22,21 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if item["type"] != "occupancy": continue - dev.append(EcobeeBinarySensor(sensor["name"], index)) + dev.append(EcobeeBinarySensor(data, sensor["name"], index)) - add_entities(dev, True) + async_add_entities(dev, True) class EcobeeBinarySensor(BinarySensorDevice): """Representation of an Ecobee sensor.""" - def __init__(self, sensor_name, sensor_index): + def __init__(self, data, sensor_name, sensor_index): """Initialize the Ecobee sensor.""" + self.data = data self._name = sensor_name + " Occupancy" self.sensor_name = sensor_name self.index = sensor_index self._state = None - self._device_class = "occupancy" @property def name(self): @@ -46,13 +51,12 @@ class EcobeeBinarySensor(BinarySensorDevice): @property def device_class(self): """Return the class of this sensor, from DEVICE_CLASSES.""" - return self._device_class + return DEVICE_CLASS_OCCUPANCY - def update(self): + async def async_update(self): """Get the latest state of the sensor.""" - data = ecobee.NETWORK - data.update() - for sensor in data.ecobee.get_remote_sensors(self.index): + await self.data.update() + for sensor in self.data.ecobee.get_remote_sensors(self.index): for item in sensor["capability"]: if item["type"] == "occupancy" and self.sensor_name == sensor["name"]: self._state = item["value"] diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index 181f1561eba..9eb8e8f26bc 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -1,14 +1,11 @@ """Support for Ecobee Thermostats.""" import collections -import logging from typing import Optional import voluptuous as vol -from homeassistant.components import ecobee from homeassistant.components.climate import ClimateDevice from homeassistant.components.climate.const import ( - DOMAIN, HVAC_MODE_COOL, HVAC_MODE_HEAT, HVAC_MODE_AUTO, @@ -38,8 +35,7 @@ from homeassistant.const import ( ) import homeassistant.helpers.config_validation as cv -_CONFIGURING = {} -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN, _LOGGER ATTR_FAN_MIN_ON_TIME = "fan_min_on_time" ATTR_RESUME_ALL = "resume_all" @@ -88,8 +84,8 @@ PRESET_TO_ECOBEE_HOLD = { PRESET_HOLD_INDEFINITE: "indefinite", } -SERVICE_SET_FAN_MIN_ON_TIME = "ecobee_set_fan_min_on_time" -SERVICE_RESUME_PROGRAM = "ecobee_resume_program" +SERVICE_SET_FAN_MIN_ON_TIME = "set_fan_min_on_time" +SERVICE_RESUME_PROGRAM = "resume_program" SET_FAN_MIN_ON_TIME_SCHEMA = vol.Schema( { @@ -114,20 +110,19 @@ SUPPORT_FLAGS = ( ) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Ecobee Thermostat Platform.""" - if discovery_info is None: - return - data = ecobee.NETWORK - hold_temp = discovery_info["hold_temp"] - _LOGGER.info( - "Loading ecobee thermostat component with hold_temp set to %s", hold_temp - ) - devices = [ - Thermostat(data, index, hold_temp) - for index in range(len(data.ecobee.thermostats)) - ] - add_entities(devices) +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up ecobee thermostat.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the ecobee thermostat.""" + + data = hass.data[DOMAIN] + + devices = [Thermostat(data, index) for index in range(len(data.ecobee.thermostats))] + + async_add_entities(devices, True) def fan_min_on_time_set_service(service): """Set the minimum fan on time on the target thermostats.""" @@ -163,14 +158,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): thermostat.schedule_update_ha_state(True) - hass.services.register( + hass.services.async_register( DOMAIN, SERVICE_SET_FAN_MIN_ON_TIME, fan_min_on_time_set_service, schema=SET_FAN_MIN_ON_TIME_SCHEMA, ) - hass.services.register( + hass.services.async_register( DOMAIN, SERVICE_RESUME_PROGRAM, resume_program_set_service, @@ -181,13 +176,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class Thermostat(ClimateDevice): """A thermostat class for Ecobee.""" - def __init__(self, data, thermostat_index, hold_temp): + def __init__(self, data, thermostat_index): """Initialize the thermostat.""" self.data = data self.thermostat_index = thermostat_index self.thermostat = self.data.ecobee.get_thermostat(self.thermostat_index) self._name = self.thermostat["name"] - self.hold_temp = hold_temp self.vacation = None self._operation_list = [] @@ -206,14 +200,13 @@ class Thermostat(ClimateDevice): self._fan_modes = [FAN_AUTO, FAN_ON] self.update_without_throttle = False - def update(self): + async def async_update(self): """Get the latest state from the thermostat.""" if self.update_without_throttle: - self.data.update(no_throttle=True) + await self.data.update(no_throttle=True) self.update_without_throttle = False else: - self.data.update() - + await self.data.update() self.thermostat = self.data.ecobee.get_thermostat(self.thermostat_index) @property diff --git a/homeassistant/components/ecobee/config_flow.py b/homeassistant/components/ecobee/config_flow.py new file mode 100644 index 00000000000..f4cd4fc5bf0 --- /dev/null +++ b/homeassistant/components/ecobee/config_flow.py @@ -0,0 +1,120 @@ +"""Config flow to configure ecobee.""" +import voluptuous as vol + +from pyecobee import ( + Ecobee, + ECOBEE_CONFIG_FILENAME, + ECOBEE_API_KEY, + ECOBEE_REFRESH_TOKEN, +) + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistantError +from homeassistant.util.json import load_json + +from .const import CONF_REFRESH_TOKEN, DATA_ECOBEE_CONFIG, DOMAIN, _LOGGER + + +class EcobeeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle an ecobee config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize the ecobee flow.""" + self._ecobee = None + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + if self._async_current_entries(): + # Config entry already exists, only one allowed. + return self.async_abort(reason="one_instance_only") + + errors = {} + stored_api_key = self.hass.data[DATA_ECOBEE_CONFIG].get(CONF_API_KEY) + + if user_input is not None: + # Use the user-supplied API key to attempt to obtain a PIN from ecobee. + self._ecobee = Ecobee(config={ECOBEE_API_KEY: user_input[CONF_API_KEY]}) + + if await self.hass.async_add_executor_job(self._ecobee.request_pin): + # We have a PIN; move to the next step of the flow. + return await self.async_step_authorize() + errors["base"] = "pin_request_failed" + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_API_KEY, default=stored_api_key): str} + ), + errors=errors, + ) + + async def async_step_authorize(self, user_input=None): + """Present the user with the PIN so that the app can be authorized on ecobee.com.""" + errors = {} + + if user_input is not None: + # Attempt to obtain tokens from ecobee and finish the flow. + if await self.hass.async_add_executor_job(self._ecobee.request_tokens): + # Refresh token obtained; create the config entry. + config = { + CONF_API_KEY: self._ecobee.api_key, + CONF_REFRESH_TOKEN: self._ecobee.refresh_token, + } + return self.async_create_entry(title=DOMAIN, data=config) + errors["base"] = "token_request_failed" + + return self.async_show_form( + step_id="authorize", + errors=errors, + description_placeholders={"pin": self._ecobee.pin}, + ) + + async def async_step_import(self, import_data): + """ + Import ecobee config from configuration.yaml. + + Triggered by async_setup only if a config entry doesn't already exist. + If ecobee.conf exists, we will attempt to validate the credentials + and create an entry if valid. Otherwise, we will delegate to the user + step so that the user can continue the config flow. + """ + try: + legacy_config = await self.hass.async_add_executor_job( + load_json, self.hass.config.path(ECOBEE_CONFIG_FILENAME) + ) + config = { + ECOBEE_API_KEY: legacy_config[ECOBEE_API_KEY], + ECOBEE_REFRESH_TOKEN: legacy_config[ECOBEE_REFRESH_TOKEN], + } + except (HomeAssistantError, KeyError): + _LOGGER.debug( + "No valid ecobee.conf configuration found for import, delegating to user step" + ) + return await self.async_step_user( + user_input={ + CONF_API_KEY: self.hass.data[DATA_ECOBEE_CONFIG].get(CONF_API_KEY) + } + ) + + ecobee = Ecobee(config=config) + if await self.hass.async_add_executor_job(ecobee.refresh_tokens): + # Credentials found and validated; create the entry. + _LOGGER.debug( + "Valid ecobee configuration found for import, creating config entry" + ) + return self.async_create_entry( + title=DOMAIN, + data={ + CONF_API_KEY: ecobee.api_key, + CONF_REFRESH_TOKEN: ecobee.refresh_token, + }, + ) + return await self.async_step_user( + user_input={ + CONF_API_KEY: self.hass.data[DATA_ECOBEE_CONFIG].get(CONF_API_KEY) + } + ) diff --git a/homeassistant/components/ecobee/const.py b/homeassistant/components/ecobee/const.py new file mode 100644 index 00000000000..c3a23099b8a --- /dev/null +++ b/homeassistant/components/ecobee/const.py @@ -0,0 +1,12 @@ +"""Constants for the ecobee integration.""" +import logging + +_LOGGER = logging.getLogger(__package__) + +DOMAIN = "ecobee" +DATA_ECOBEE_CONFIG = "ecobee_config" + +CONF_INDEX = "index" +CONF_REFRESH_TOKEN = "refresh_token" + +ECOBEE_PLATFORMS = ["binary_sensor", "climate", "sensor", "weather"] diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json index 31cca1e676f..092594c41fc 100644 --- a/homeassistant/components/ecobee/manifest.json +++ b/homeassistant/components/ecobee/manifest.json @@ -1,10 +1,9 @@ { - "domain": "ecobee", - "name": "Ecobee", - "documentation": "https://www.home-assistant.io/components/ecobee", - "requirements": [ - "python-ecobee-api==0.0.21" - ], - "dependencies": ["configurator"], - "codeowners": [] + "domain": "ecobee", + "name": "Ecobee", + "config_flow": true, + "documentation": "https://www.home-assistant.io/components/ecobee", + "dependencies": [], + "requirements": ["python-ecobee-api==0.1.2"], + "codeowners": ["@marthoc"] } diff --git a/homeassistant/components/ecobee/notify.py b/homeassistant/components/ecobee/notify.py index bb6861a1492..c7b3f47d29c 100644 --- a/homeassistant/components/ecobee/notify.py +++ b/homeassistant/components/ecobee/notify.py @@ -1,15 +1,10 @@ """Support for Ecobee Send Message service.""" -import logging - import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.components import ecobee from homeassistant.components.notify import BaseNotificationService, PLATFORM_SCHEMA -_LOGGER = logging.getLogger(__name__) - -CONF_INDEX = "index" +from .const import CONF_INDEX, DOMAIN PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( {vol.Optional(CONF_INDEX, default=0): cv.positive_int} @@ -18,17 +13,19 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def get_service(hass, config, discovery_info=None): """Get the Ecobee notification service.""" + data = hass.data[DOMAIN] index = config.get(CONF_INDEX) - return EcobeeNotificationService(index) + return EcobeeNotificationService(data, index) class EcobeeNotificationService(BaseNotificationService): """Implement the notification service for the Ecobee thermostat.""" - def __init__(self, thermostat_index): + def __init__(self, data, thermostat_index): """Initialize the service.""" + self.data = data self.thermostat_index = thermostat_index def send_message(self, message="", **kwargs): - """Send a message to a command line.""" - ecobee.NETWORK.ecobee.send_message(self.thermostat_index, message) + """Send a message.""" + self.data.ecobee.send_message(self.thermostat_index, message) diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py index d21f937dd20..e62d68dc9bc 100644 --- a/homeassistant/components/ecobee/sensor.py +++ b/homeassistant/components/ecobee/sensor.py @@ -1,5 +1,6 @@ """Support for Ecobee sensors.""" -from homeassistant.components import ecobee +from pyecobee.const import ECOBEE_STATE_CALIBRATING, ECOBEE_STATE_UNKNOWN + from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -7,7 +8,7 @@ from homeassistant.const import ( ) from homeassistant.helpers.entity import Entity -ECOBEE_CONFIG_FILE = "ecobee.conf" +from .const import DOMAIN SENSOR_TYPES = { "temperature": ["Temperature", TEMP_FAHRENHEIT], @@ -15,11 +16,14 @@ SENSOR_TYPES = { } -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Ecobee sensors.""" - if discovery_info is None: - return - data = ecobee.NETWORK +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up ecobee sensors.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up ecobee (temperature and humidity) sensors.""" + data = hass.data[DOMAIN] dev = list() for index in range(len(data.ecobee.thermostats)): for sensor in data.ecobee.get_remote_sensors(index): @@ -27,16 +31,17 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if item["type"] not in ("temperature", "humidity"): continue - dev.append(EcobeeSensor(sensor["name"], item["type"], index)) + dev.append(EcobeeSensor(data, sensor["name"], item["type"], index)) - add_entities(dev, True) + async_add_entities(dev, True) class EcobeeSensor(Entity): """Representation of an Ecobee sensor.""" - def __init__(self, sensor_name, sensor_type, sensor_index): + def __init__(self, data, sensor_name, sensor_type, sensor_index): """Initialize the sensor.""" + self.data = data self._name = "{} {}".format(sensor_name, SENSOR_TYPES[sensor_type][0]) self.sensor_name = sensor_name self.type = sensor_type @@ -59,6 +64,12 @@ class EcobeeSensor(Entity): @property def state(self): """Return the state of the sensor.""" + if self._state in [ECOBEE_STATE_CALIBRATING, ECOBEE_STATE_UNKNOWN]: + return None + + if self.type == "temperature": + return float(self._state) / 10 + return self._state @property @@ -66,14 +77,10 @@ class EcobeeSensor(Entity): """Return the unit of measurement this sensor expresses itself in.""" return self._unit_of_measurement - def update(self): + async def async_update(self): """Get the latest state of the sensor.""" - data = ecobee.NETWORK - data.update() - for sensor in data.ecobee.get_remote_sensors(self.index): + await self.data.update() + for sensor in self.data.ecobee.get_remote_sensors(self.index): for item in sensor["capability"]: if item["type"] == self.type and self.sensor_name == sensor["name"]: - if self.type == "temperature" and item["value"] != "unknown": - self._state = float(item["value"]) / 10 - else: - self._state = item["value"] + self._state = item["value"] diff --git a/homeassistant/components/ecobee/services.yaml b/homeassistant/components/ecobee/services.yaml index e69de29bb2d..87eefed9eaa 100644 --- a/homeassistant/components/ecobee/services.yaml +++ b/homeassistant/components/ecobee/services.yaml @@ -0,0 +1,19 @@ +resume_program: + description: Resume the programmed schedule. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.kitchen' + resume_all: + description: Resume all events and return to the scheduled program. This default to false which removes only the top event. + example: true + +set_fan_min_on_time: + description: Set the minimum fan on time. + fields: + entity_id: + description: Name(s) of entities to change. + example: 'climate.kitchen' + fan_min_on_time: + description: New value of fan min on time. + example: 5 diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json new file mode 100644 index 00000000000..9e7e9fed396 --- /dev/null +++ b/homeassistant/components/ecobee/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "title": "ecobee", + "step": { + "user": { + "title": "ecobee API key", + "description": "Please enter the API key obtained from ecobee.com.", + "data": {"api_key": "API Key"} + }, + "authorize": { + "title": "Authorize app on ecobee.com", + "description": "Please authorize this app at https://www.ecobee.com/consumerportal/index.html with pin code:\n\n{pin}\n\nThen, press Submit." + } + }, + "error": { + "pin_request_failed": "Error requesting PIN from ecobee; please verify API key is correct.", + "token_request_failed": "Error requesting tokens from ecobee; please try again." + }, + "abort": { + "one_instance_only": "This integration currently supports only one ecobee instance." + } + } +} diff --git a/homeassistant/components/ecobee/weather.py b/homeassistant/components/ecobee/weather.py index b09e06bd822..dd3112b636e 100644 --- a/homeassistant/components/ecobee/weather.py +++ b/homeassistant/components/ecobee/weather.py @@ -1,7 +1,8 @@ """Support for displaying weather info from Ecobee API.""" from datetime import datetime -from homeassistant.components import ecobee +from pyecobee.const import ECOBEE_STATE_UNKNOWN + from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP, @@ -12,33 +13,37 @@ from homeassistant.components.weather import ( ) from homeassistant.const import TEMP_FAHRENHEIT +from .const import DOMAIN + ATTR_FORECAST_TEMP_HIGH = "temphigh" ATTR_FORECAST_PRESSURE = "pressure" ATTR_FORECAST_VISIBILITY = "visibility" ATTR_FORECAST_HUMIDITY = "humidity" -MISSING_DATA = -5002 + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up the ecobee weather platform.""" + pass -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Ecobee weather platform.""" - if discovery_info is None: - return +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the ecobee weather platform.""" + data = hass.data[DOMAIN] dev = list() - data = ecobee.NETWORK for index in range(len(data.ecobee.thermostats)): thermostat = data.ecobee.get_thermostat(index) if "weather" in thermostat: - dev.append(EcobeeWeather(thermostat["name"], index)) + dev.append(EcobeeWeather(data, thermostat["name"], index)) - add_entities(dev, True) + async_add_entities(dev, True) class EcobeeWeather(WeatherEntity): """Representation of Ecobee weather data.""" - def __init__(self, name, index): + def __init__(self, data, name, index): """Initialize the Ecobee weather platform.""" + self.data = data self._name = name self._index = index self.weather = None @@ -140,26 +145,25 @@ class EcobeeWeather(WeatherEntity): ATTR_FORECAST_CONDITION: day["condition"], ATTR_FORECAST_TEMP: float(day["tempHigh"]) / 10, } - if day["tempHigh"] == MISSING_DATA: + if day["tempHigh"] == ECOBEE_STATE_UNKNOWN: break - if day["tempLow"] != MISSING_DATA: + if day["tempLow"] != ECOBEE_STATE_UNKNOWN: forecast[ATTR_FORECAST_TEMP_LOW] = float(day["tempLow"]) / 10 - if day["pressure"] != MISSING_DATA: + if day["pressure"] != ECOBEE_STATE_UNKNOWN: forecast[ATTR_FORECAST_PRESSURE] = int(day["pressure"]) - if day["windSpeed"] != MISSING_DATA: + if day["windSpeed"] != ECOBEE_STATE_UNKNOWN: forecast[ATTR_FORECAST_WIND_SPEED] = int(day["windSpeed"]) - if day["visibility"] != MISSING_DATA: + if day["visibility"] != ECOBEE_STATE_UNKNOWN: forecast[ATTR_FORECAST_WIND_SPEED] = int(day["visibility"]) - if day["relativeHumidity"] != MISSING_DATA: + if day["relativeHumidity"] != ECOBEE_STATE_UNKNOWN: forecast[ATTR_FORECAST_HUMIDITY] = int(day["relativeHumidity"]) forecasts.append(forecast) return forecasts except (ValueError, IndexError, KeyError): return None - def update(self): - """Get the latest state of the sensor.""" - data = ecobee.NETWORK - data.update() - thermostat = data.ecobee.get_thermostat(self._index) + async def async_update(self): + """Get the latest weather data.""" + await self.data.update() + thermostat = self.data.ecobee.get_thermostat(self._index) self.weather = thermostat.get("weather", None) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 9a534c01bbf..b6865f9e86a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -15,6 +15,7 @@ FLOWS = [ "daikin", "deconz", "dialogflow", + "ecobee", "emulated_roku", "esphome", "geofency", diff --git a/requirements_all.txt b/requirements_all.txt index 26fdfc38fa3..5ca7a7c8160 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1483,7 +1483,7 @@ python-clementine-remote==1.0.1 python-digitalocean==1.13.2 # homeassistant.components.ecobee -python-ecobee-api==0.0.21 +python-ecobee-api==0.1.2 # homeassistant.components.eq3btsmart # python-eq3bt==0.1.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6f542147363..d6c0d8dbbb2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -355,6 +355,9 @@ pysonos==0.0.23 # homeassistant.components.spc pyspcwebgw==0.4.0 +# homeassistant.components.ecobee +python-ecobee-api==0.1.2 + # homeassistant.components.darksky python-forecastio==1.4.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 649c48e1b7d..fcb265bbc97 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -146,6 +146,7 @@ TEST_REQUIREMENTS = ( "pysonos", "pyspcwebgw", "python_awair", + "python-ecobee-api", "python-forecastio", "python-izone", "python-nest", diff --git a/tests/components/ecobee/test_climate.py b/tests/components/ecobee/test_climate.py index d6c40ddf9ab..90a9a641776 100644 --- a/tests/components/ecobee/test_climate.py +++ b/tests/components/ecobee/test_climate.py @@ -54,7 +54,7 @@ class TestEcobee(unittest.TestCase): self.data = mock.Mock() self.data.ecobee.get_thermostat.return_value = self.ecobee - self.thermostat = ecobee.Thermostat(self.data, 1, False) + self.thermostat = ecobee.Thermostat(self.data, 1) def test_name(self): """Test name property.""" diff --git a/tests/components/ecobee/test_config_flow.py b/tests/components/ecobee/test_config_flow.py new file mode 100644 index 00000000000..7b4d1f96a37 --- /dev/null +++ b/tests/components/ecobee/test_config_flow.py @@ -0,0 +1,206 @@ +"""Tests for the ecobee config flow.""" +from unittest.mock import patch + +from pyecobee import ECOBEE_API_KEY, ECOBEE_REFRESH_TOKEN + +from homeassistant import data_entry_flow +from homeassistant.components.ecobee import config_flow +from homeassistant.components.ecobee.const import ( + CONF_REFRESH_TOKEN, + DATA_ECOBEE_CONFIG, + DOMAIN, +) +from homeassistant.const import CONF_API_KEY +from tests.common import MockConfigEntry, mock_coro + + +async def test_abort_if_already_setup(hass): + """Test we abort if ecobee is already setup.""" + flow = config_flow.EcobeeFlowHandler() + flow.hass = hass + + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + + result = await flow.async_step_user() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "one_instance_only" + + +async def test_user_step_without_user_input(hass): + """Test expected result if user step is called.""" + flow = config_flow.EcobeeFlowHandler() + flow.hass = hass + flow.hass.data[DATA_ECOBEE_CONFIG] = {} + + result = await flow.async_step_user() + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + +async def test_pin_request_succeeds(hass): + """Test expected result if pin request succeeds.""" + flow = config_flow.EcobeeFlowHandler() + flow.hass = hass + flow.hass.data[DATA_ECOBEE_CONFIG] = {} + + with patch("homeassistant.components.ecobee.config_flow.Ecobee") as MockEcobee: + mock_ecobee = MockEcobee.return_value + mock_ecobee.request_pin.return_value = True + mock_ecobee.pin = "test-pin" + + result = await flow.async_step_user(user_input={CONF_API_KEY: "api-key"}) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "authorize" + assert result["description_placeholders"] == {"pin": "test-pin"} + + +async def test_pin_request_fails(hass): + """Test expected result if pin request fails.""" + flow = config_flow.EcobeeFlowHandler() + flow.hass = hass + flow.hass.data[DATA_ECOBEE_CONFIG] = {} + + with patch("homeassistant.components.ecobee.config_flow.Ecobee") as MockEcobee: + mock_ecobee = MockEcobee.return_value + mock_ecobee.request_pin.return_value = False + + result = await flow.async_step_user(user_input={CONF_API_KEY: "api-key"}) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == "pin_request_failed" + + +async def test_token_request_succeeds(hass): + """Test expected result if token request succeeds.""" + flow = config_flow.EcobeeFlowHandler() + flow.hass = hass + flow.hass.data[DATA_ECOBEE_CONFIG] = {} + + with patch("homeassistant.components.ecobee.config_flow.Ecobee") as MockEcobee: + mock_ecobee = MockEcobee.return_value + mock_ecobee.request_tokens.return_value = True + mock_ecobee.api_key = "test-api-key" + mock_ecobee.refresh_token = "test-token" + flow._ecobee = mock_ecobee + + result = await flow.async_step_authorize(user_input={}) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == DOMAIN + assert result["data"] == { + CONF_API_KEY: "test-api-key", + CONF_REFRESH_TOKEN: "test-token", + } + + +async def test_token_request_fails(hass): + """Test expected result if token request fails.""" + flow = config_flow.EcobeeFlowHandler() + flow.hass = hass + flow.hass.data[DATA_ECOBEE_CONFIG] = {} + + with patch("homeassistant.components.ecobee.config_flow.Ecobee") as MockEcobee: + mock_ecobee = MockEcobee.return_value + mock_ecobee.request_tokens.return_value = False + mock_ecobee.pin = "test-pin" + flow._ecobee = mock_ecobee + + result = await flow.async_step_authorize(user_input={}) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "authorize" + assert result["errors"]["base"] == "token_request_failed" + assert result["description_placeholders"] == {"pin": "test-pin"} + + +async def test_import_flow_triggered_but_no_ecobee_conf(hass): + """Test expected result if import flow triggers but ecobee.conf doesn't exist.""" + flow = config_flow.EcobeeFlowHandler() + flow.hass = hass + flow.hass.data[DATA_ECOBEE_CONFIG] = {} + + result = await flow.async_step_import(import_data=None) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + +async def test_import_flow_triggered_with_ecobee_conf_and_valid_data_and_valid_tokens( + hass +): + """Test expected result if import flow triggers and ecobee.conf exists with valid tokens.""" + flow = config_flow.EcobeeFlowHandler() + flow.hass = hass + + MOCK_ECOBEE_CONF = {ECOBEE_API_KEY: None, ECOBEE_REFRESH_TOKEN: None} + + with patch( + "homeassistant.components.ecobee.config_flow.load_json", + return_value=MOCK_ECOBEE_CONF, + ), patch("homeassistant.components.ecobee.config_flow.Ecobee") as MockEcobee: + mock_ecobee = MockEcobee.return_value + mock_ecobee.refresh_tokens.return_value = True + mock_ecobee.api_key = "test-api-key" + mock_ecobee.refresh_token = "test-token" + + result = await flow.async_step_import(import_data=None) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == DOMAIN + assert result["data"] == { + CONF_API_KEY: "test-api-key", + CONF_REFRESH_TOKEN: "test-token", + } + + +async def test_import_flow_triggered_with_ecobee_conf_and_invalid_data(hass): + """Test expected result if import flow triggers and ecobee.conf exists with invalid data.""" + flow = config_flow.EcobeeFlowHandler() + flow.hass = hass + flow.hass.data[DATA_ECOBEE_CONFIG] = {CONF_API_KEY: "test-api-key"} + + MOCK_ECOBEE_CONF = {} + + with patch( + "homeassistant.components.ecobee.config_flow.load_json", + return_value=MOCK_ECOBEE_CONF, + ), patch.object( + flow, "async_step_user", return_value=mock_coro() + ) as mock_async_step_user: + + await flow.async_step_import(import_data=None) + + mock_async_step_user.assert_called_once_with( + user_input={CONF_API_KEY: "test-api-key"} + ) + + +async def test_import_flow_triggered_with_ecobee_conf_and_valid_data_and_stale_tokens( + hass +): + """Test expected result if import flow triggers and ecobee.conf exists with stale tokens.""" + flow = config_flow.EcobeeFlowHandler() + flow.hass = hass + flow.hass.data[DATA_ECOBEE_CONFIG] = {CONF_API_KEY: "test-api-key"} + + MOCK_ECOBEE_CONF = {ECOBEE_API_KEY: None, ECOBEE_REFRESH_TOKEN: None} + + with patch( + "homeassistant.components.ecobee.config_flow.load_json", + return_value=MOCK_ECOBEE_CONF, + ), patch( + "homeassistant.components.ecobee.config_flow.Ecobee" + ) as MockEcobee, patch.object( + flow, "async_step_user", return_value=mock_coro() + ) as mock_async_step_user: + mock_ecobee = MockEcobee.return_value + mock_ecobee.refresh_tokens.return_value = False + + await flow.async_step_import(import_data=None) + + mock_async_step_user.assert_called_once_with( + user_input={CONF_API_KEY: "test-api-key"} + )