Add config flow to ecobee (#26634)

* Add basic config flow

* Fix json files

* Update __init__.py

* Fix json errors

* Move constants to const.py

* Add ecobee to generated config flows

* Update config_flow for updated API

* Update manifest to include new dependencies

Bump pyecobee, add aiofiles.

* Update constants for ecobee

* Modify ecobee setup to use config flow

* Bump dependency

* Update binary_sensor to use config_entry

* Update sensor to use config_entry

* Update __init__.py

* Update weather to use config_entry

* Update notify.py

* Update ecobee constants

* Update climate to use config_entry

* Avoid a breaking change on ecobee services

* Store api key from old config entry

* Allow unloading of config entry

* Show user a form before import

* Refine import flow

* Update strings.json to remove import step

Not needed.

* Move third party imports to top of module

* Remove periods from end of log messages

* Make configuration.yaml config optional

* Remove unused strings

* Reorganize config flow

* Remove unneeded requirement

* No need to store API key

* Update async_unload_entry

* Clean up if/else statements

* Update requirements_all.txt

* Fix config schema

* Update __init__.py

* Remove check for DATA_ECOBEE_CONFIG

* Remove redundant check

* Add check for DATA_ECOBEE_CONFIG

* Change setup_platform to async

* Fix state unknown and imports

* Change init step to user

* Have import step raise specific exceptions

* Rearrange try/except block in import flow

* Convert update() and refresh() to coroutines

...and update platforms to use async_update coroutine.

* Finish converting init to async

* Preliminary tests

* Test full implementation

* Update test_config_flow.py

* Update test_config_flow.py

* Add self to codeowners

* Update test_config_flow.py

* Use MockConfigEntry

* Update test_config_flow.py

* Update CODEOWNERS

* pylint fixes

* Register services under ecobee domain

Breaking change!

* Pylint fixes

* Pylint fixes

* Pylint fixes

* Move service strings to ecobee domain

* Fix log message capitalization

* Fix import formatting

* Update .coveragerc

* Add __init__ to coveragerc

* Add option flow test

* Update .coveragerc

* Act on updated options

* Revert "Act on updated options"

This reverts commit 56b0a859f2e3e80b6f4c77a8f784a2b29ee2cce9.

* Remove hold_temp from climate

* Remove hold_temp and options from init

* Remove options handler from config flow

* Remove options strings

* Remove options flow test

* Remove hold_temp constants

* Fix climate tests

* Pass api key to user step in import flow

* Update test_config_flow.py

Ensure that the import step calls the user step with the user's api key as user input if importing from ecobee.conf/validating imported keys fails.
This commit is contained in:
Mark Coombes 2019-09-25 16:38:21 -04:00 committed by Martin Hjelmare
parent cff7fd0ef3
commit f6995b8d17
21 changed files with 623 additions and 218 deletions

View File

@ -156,7 +156,12 @@ omit =
homeassistant/components/ebox/sensor.py homeassistant/components/ebox/sensor.py
homeassistant/components/ebusd/* homeassistant/components/ebusd/*
homeassistant/components/ecoal_boiler/* 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/econet/water_heater.py
homeassistant/components/ecovacs/* homeassistant/components/ecovacs/*
homeassistant/components/eddystone_temperature/sensor.py homeassistant/components/eddystone_temperature/sensor.py

View File

@ -73,6 +73,7 @@ homeassistant/components/digital_ocean/* @fabaff
homeassistant/components/discogs/* @thibmaek homeassistant/components/discogs/* @thibmaek
homeassistant/components/doorbird/* @oblogic7 homeassistant/components/doorbird/* @oblogic7
homeassistant/components/dweet/* @fabaff homeassistant/components/dweet/* @fabaff
homeassistant/components/ecobee/* @marthoc
homeassistant/components/ecovacs/* @OverloadUT homeassistant/components/ecovacs/* @OverloadUT
homeassistant/components/egardia/* @jeroenterheerdt homeassistant/components/egardia/* @jeroenterheerdt
homeassistant/components/eight_sleep/* @mezz64 homeassistant/components/eight_sleep/* @mezz64

View File

@ -72,26 +72,6 @@ set_swing_mode:
swing_mode: swing_mode:
description: New value of 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: mill_set_room_temperature:
description: Set Mill room temperatures. description: Set Mill room temperatures.
fields: fields:

View File

@ -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."
}
}
}

View File

@ -1,123 +1,130 @@
"""Support for Ecobee devices.""" """Support for ecobee."""
import logging import asyncio
import os
from datetime import timedelta from datetime import timedelta
import voluptuous as vol import voluptuous as vol
import homeassistant.helpers.config_validation as cv from pyecobee import Ecobee, ECOBEE_API_KEY, ECOBEE_REFRESH_TOKEN, ExpiredTokenError
from homeassistant.helpers import discovery
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_API_KEY from homeassistant.const import CONF_API_KEY
from homeassistant.helpers import config_validation as cv
from homeassistant.util import Throttle from homeassistant.util import Throttle
from homeassistant.util.json import save_json
_CONFIGURING = {} from .const import (
_LOGGER = logging.getLogger(__name__) CONF_REFRESH_TOKEN,
DATA_ECOBEE_CONFIG,
CONF_HOLD_TEMP = "hold_temp" DOMAIN,
ECOBEE_PLATFORMS,
DOMAIN = "ecobee" _LOGGER,
)
ECOBEE_CONFIG_FILE = "ecobee.conf"
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180) MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180)
NETWORK = None
CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema(
{ {DOMAIN: vol.Schema({vol.Optional(CONF_API_KEY): cv.string})}, extra=vol.ALLOW_EXTRA
DOMAIN: vol.Schema(
{
vol.Optional(CONF_API_KEY): cv.string,
vol.Optional(CONF_HOLD_TEMP, default=False): cv.boolean,
}
)
},
extra=vol.ALLOW_EXTRA,
) )
def request_configuration(network, hass, config): async def async_setup(hass, config):
"""Request configuration steps from the user.""" """
configurator = hass.components.configurator Ecobee uses config flow for configuration.
if "ecobee" in _CONFIGURING:
configurator.notify_errors( But, an "ecobee:" entry in configuration.yaml will trigger an import flow
_CONFIGURING["ecobee"], "Failed to register, please try again." 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 return True
def ecobee_configuration_callback(callback_data):
"""Handle configuration callbacks."""
network.request_tokens()
network.update()
setup_ecobee(hass, network, config)
_CONFIGURING["ecobee"] = configurator.request_config( async def async_setup_entry(hass, entry):
"Ecobee", """Set up ecobee via a config entry."""
ecobee_configuration_callback, api_key = entry.data[CONF_API_KEY]
description=( refresh_token = entry.data[CONF_REFRESH_TOKEN]
"Please authorize this app at https://www.ecobee.com/consumer"
"portal/index.html with pin code: " + network.pin data = EcobeeData(hass, entry, api_key=api_key, refresh_token=refresh_token)
),
description_image="/static/images/config_ecobee_thermostat.png", if not await data.refresh():
submit_caption="I have authorized the app.", return False
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
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
if "ecobee" in _CONFIGURING:
configurator = hass.components.configurator
configurator.request_done(_CONFIGURING.pop("ecobee"))
hold_temp = config[DOMAIN].get(CONF_HOLD_TEMP)
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)
class EcobeeData: 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): Also handle refreshing tokens and updating config entry with refreshed tokens.
"""Init the Ecobee data object.""" """
from pyecobee import Ecobee
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) @Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self): async def update(self):
"""Get the latest data from pyecobee.""" """Get the latest data from ecobee.com."""
self.ecobee.update() try:
_LOGGER.debug("Ecobee data updated successfully") await self._hass.async_add_executor_job(self.ecobee.update)
_LOGGER.debug("Updating ecobee")
except ExpiredTokenError:
def setup(hass, config): _LOGGER.warning(
"""Set up the Ecobee. "Ecobee update failed; attempting to refresh expired tokens"
)
Will automatically load thermostat and sensor components to support await self.refresh()
devices discovered on the network.
"""
global NETWORK
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)
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 return True
_LOGGER.error("Error updating ecobee tokens")
return False
async def async_unload_entry(hass, config_entry):
"""Unload the config entry and platforms."""
hass.data.pop(DOMAIN)
tasks = []
for platform in ECOBEE_PLATFORMS:
tasks.append(
hass.config_entries.async_forward_entry_unload(config_entry, platform)
)
return all(await asyncio.gather(*tasks))

View File

@ -1,15 +1,20 @@
"""Support for Ecobee binary sensors.""" """Support for Ecobee binary sensors."""
from homeassistant.components import ecobee from homeassistant.components.binary_sensor import (
from homeassistant.components.binary_sensor import BinarySensorDevice BinarySensorDevice,
DEVICE_CLASS_OCCUPANCY,
)
ECOBEE_CONFIG_FILE = "ecobee.conf" from .const import DOMAIN
def setup_platform(hass, config, add_entities, discovery_info=None): async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the Ecobee sensors.""" """Old way of setting up ecobee binary sensors."""
if discovery_info is None: pass
return
data = ecobee.NETWORK
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up ecobee binary (occupancy) sensors."""
data = hass.data[DOMAIN]
dev = list() dev = list()
for index in range(len(data.ecobee.thermostats)): for index in range(len(data.ecobee.thermostats)):
for sensor in data.ecobee.get_remote_sensors(index): 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": if item["type"] != "occupancy":
continue 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): class EcobeeBinarySensor(BinarySensorDevice):
"""Representation of an Ecobee sensor.""" """Representation of an Ecobee sensor."""
def __init__(self, sensor_name, sensor_index): def __init__(self, data, sensor_name, sensor_index):
"""Initialize the Ecobee sensor.""" """Initialize the Ecobee sensor."""
self.data = data
self._name = sensor_name + " Occupancy" self._name = sensor_name + " Occupancy"
self.sensor_name = sensor_name self.sensor_name = sensor_name
self.index = sensor_index self.index = sensor_index
self._state = None self._state = None
self._device_class = "occupancy"
@property @property
def name(self): def name(self):
@ -46,13 +51,12 @@ class EcobeeBinarySensor(BinarySensorDevice):
@property @property
def device_class(self): def device_class(self):
"""Return the class of this sensor, from DEVICE_CLASSES.""" """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.""" """Get the latest state of the sensor."""
data = ecobee.NETWORK await self.data.update()
data.update() for sensor in self.data.ecobee.get_remote_sensors(self.index):
for sensor in data.ecobee.get_remote_sensors(self.index):
for item in sensor["capability"]: for item in sensor["capability"]:
if item["type"] == "occupancy" and self.sensor_name == sensor["name"]: if item["type"] == "occupancy" and self.sensor_name == sensor["name"]:
self._state = item["value"] self._state = item["value"]

View File

@ -1,14 +1,11 @@
"""Support for Ecobee Thermostats.""" """Support for Ecobee Thermostats."""
import collections import collections
import logging
from typing import Optional from typing import Optional
import voluptuous as vol import voluptuous as vol
from homeassistant.components import ecobee
from homeassistant.components.climate import ClimateDevice from homeassistant.components.climate import ClimateDevice
from homeassistant.components.climate.const import ( from homeassistant.components.climate.const import (
DOMAIN,
HVAC_MODE_COOL, HVAC_MODE_COOL,
HVAC_MODE_HEAT, HVAC_MODE_HEAT,
HVAC_MODE_AUTO, HVAC_MODE_AUTO,
@ -38,8 +35,7 @@ from homeassistant.const import (
) )
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
_CONFIGURING = {} from .const import DOMAIN, _LOGGER
_LOGGER = logging.getLogger(__name__)
ATTR_FAN_MIN_ON_TIME = "fan_min_on_time" ATTR_FAN_MIN_ON_TIME = "fan_min_on_time"
ATTR_RESUME_ALL = "resume_all" ATTR_RESUME_ALL = "resume_all"
@ -88,8 +84,8 @@ PRESET_TO_ECOBEE_HOLD = {
PRESET_HOLD_INDEFINITE: "indefinite", PRESET_HOLD_INDEFINITE: "indefinite",
} }
SERVICE_SET_FAN_MIN_ON_TIME = "ecobee_set_fan_min_on_time" SERVICE_SET_FAN_MIN_ON_TIME = "set_fan_min_on_time"
SERVICE_RESUME_PROGRAM = "ecobee_resume_program" SERVICE_RESUME_PROGRAM = "resume_program"
SET_FAN_MIN_ON_TIME_SCHEMA = vol.Schema( SET_FAN_MIN_ON_TIME_SCHEMA = vol.Schema(
{ {
@ -114,20 +110,19 @@ SUPPORT_FLAGS = (
) )
def setup_platform(hass, config, add_entities, discovery_info=None): async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the Ecobee Thermostat Platform.""" """Old way of setting up ecobee thermostat."""
if discovery_info is None: pass
return
data = ecobee.NETWORK
hold_temp = discovery_info["hold_temp"] async def async_setup_entry(hass, config_entry, async_add_entities):
_LOGGER.info( """Set up the ecobee thermostat."""
"Loading ecobee thermostat component with hold_temp set to %s", hold_temp
) data = hass.data[DOMAIN]
devices = [
Thermostat(data, index, hold_temp) devices = [Thermostat(data, index) for index in range(len(data.ecobee.thermostats))]
for index in range(len(data.ecobee.thermostats))
] async_add_entities(devices, True)
add_entities(devices)
def fan_min_on_time_set_service(service): def fan_min_on_time_set_service(service):
"""Set the minimum fan on time on the target thermostats.""" """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) thermostat.schedule_update_ha_state(True)
hass.services.register( hass.services.async_register(
DOMAIN, DOMAIN,
SERVICE_SET_FAN_MIN_ON_TIME, SERVICE_SET_FAN_MIN_ON_TIME,
fan_min_on_time_set_service, fan_min_on_time_set_service,
schema=SET_FAN_MIN_ON_TIME_SCHEMA, schema=SET_FAN_MIN_ON_TIME_SCHEMA,
) )
hass.services.register( hass.services.async_register(
DOMAIN, DOMAIN,
SERVICE_RESUME_PROGRAM, SERVICE_RESUME_PROGRAM,
resume_program_set_service, resume_program_set_service,
@ -181,13 +176,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
class Thermostat(ClimateDevice): class Thermostat(ClimateDevice):
"""A thermostat class for Ecobee.""" """A thermostat class for Ecobee."""
def __init__(self, data, thermostat_index, hold_temp): def __init__(self, data, thermostat_index):
"""Initialize the thermostat.""" """Initialize the thermostat."""
self.data = data self.data = data
self.thermostat_index = thermostat_index self.thermostat_index = thermostat_index
self.thermostat = self.data.ecobee.get_thermostat(self.thermostat_index) self.thermostat = self.data.ecobee.get_thermostat(self.thermostat_index)
self._name = self.thermostat["name"] self._name = self.thermostat["name"]
self.hold_temp = hold_temp
self.vacation = None self.vacation = None
self._operation_list = [] self._operation_list = []
@ -206,14 +200,13 @@ class Thermostat(ClimateDevice):
self._fan_modes = [FAN_AUTO, FAN_ON] self._fan_modes = [FAN_AUTO, FAN_ON]
self.update_without_throttle = False self.update_without_throttle = False
def update(self): async def async_update(self):
"""Get the latest state from the thermostat.""" """Get the latest state from the thermostat."""
if self.update_without_throttle: if self.update_without_throttle:
self.data.update(no_throttle=True) await self.data.update(no_throttle=True)
self.update_without_throttle = False self.update_without_throttle = False
else: else:
self.data.update() await self.data.update()
self.thermostat = self.data.ecobee.get_thermostat(self.thermostat_index) self.thermostat = self.data.ecobee.get_thermostat(self.thermostat_index)
@property @property

View File

@ -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)
}
)

View File

@ -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"]

View File

@ -1,10 +1,9 @@
{ {
"domain": "ecobee", "domain": "ecobee",
"name": "Ecobee", "name": "Ecobee",
"config_flow": true,
"documentation": "https://www.home-assistant.io/components/ecobee", "documentation": "https://www.home-assistant.io/components/ecobee",
"requirements": [ "dependencies": [],
"python-ecobee-api==0.0.21" "requirements": ["python-ecobee-api==0.1.2"],
], "codeowners": ["@marthoc"]
"dependencies": ["configurator"],
"codeowners": []
} }

View File

@ -1,15 +1,10 @@
"""Support for Ecobee Send Message service.""" """Support for Ecobee Send Message service."""
import logging
import voluptuous as vol import voluptuous as vol
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.components import ecobee
from homeassistant.components.notify import BaseNotificationService, PLATFORM_SCHEMA from homeassistant.components.notify import BaseNotificationService, PLATFORM_SCHEMA
_LOGGER = logging.getLogger(__name__) from .const import CONF_INDEX, DOMAIN
CONF_INDEX = "index"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{vol.Optional(CONF_INDEX, default=0): cv.positive_int} {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): def get_service(hass, config, discovery_info=None):
"""Get the Ecobee notification service.""" """Get the Ecobee notification service."""
data = hass.data[DOMAIN]
index = config.get(CONF_INDEX) index = config.get(CONF_INDEX)
return EcobeeNotificationService(index) return EcobeeNotificationService(data, index)
class EcobeeNotificationService(BaseNotificationService): class EcobeeNotificationService(BaseNotificationService):
"""Implement the notification service for the Ecobee thermostat.""" """Implement the notification service for the Ecobee thermostat."""
def __init__(self, thermostat_index): def __init__(self, data, thermostat_index):
"""Initialize the service.""" """Initialize the service."""
self.data = data
self.thermostat_index = thermostat_index self.thermostat_index = thermostat_index
def send_message(self, message="", **kwargs): def send_message(self, message="", **kwargs):
"""Send a message to a command line.""" """Send a message."""
ecobee.NETWORK.ecobee.send_message(self.thermostat_index, message) self.data.ecobee.send_message(self.thermostat_index, message)

View File

@ -1,5 +1,6 @@
"""Support for Ecobee sensors.""" """Support for Ecobee sensors."""
from homeassistant.components import ecobee from pyecobee.const import ECOBEE_STATE_CALIBRATING, ECOBEE_STATE_UNKNOWN
from homeassistant.const import ( from homeassistant.const import (
DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TEMPERATURE,
@ -7,7 +8,7 @@ from homeassistant.const import (
) )
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
ECOBEE_CONFIG_FILE = "ecobee.conf" from .const import DOMAIN
SENSOR_TYPES = { SENSOR_TYPES = {
"temperature": ["Temperature", TEMP_FAHRENHEIT], "temperature": ["Temperature", TEMP_FAHRENHEIT],
@ -15,11 +16,14 @@ SENSOR_TYPES = {
} }
def setup_platform(hass, config, add_entities, discovery_info=None): async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the Ecobee sensors.""" """Old way of setting up ecobee sensors."""
if discovery_info is None: pass
return
data = ecobee.NETWORK
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up ecobee (temperature and humidity) sensors."""
data = hass.data[DOMAIN]
dev = list() dev = list()
for index in range(len(data.ecobee.thermostats)): for index in range(len(data.ecobee.thermostats)):
for sensor in data.ecobee.get_remote_sensors(index): 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"): if item["type"] not in ("temperature", "humidity"):
continue 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): class EcobeeSensor(Entity):
"""Representation of an Ecobee sensor.""" """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.""" """Initialize the sensor."""
self.data = data
self._name = "{} {}".format(sensor_name, SENSOR_TYPES[sensor_type][0]) self._name = "{} {}".format(sensor_name, SENSOR_TYPES[sensor_type][0])
self.sensor_name = sensor_name self.sensor_name = sensor_name
self.type = sensor_type self.type = sensor_type
@ -59,6 +64,12 @@ class EcobeeSensor(Entity):
@property @property
def state(self): def state(self):
"""Return the state of the sensor.""" """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 return self._state
@property @property
@ -66,14 +77,10 @@ class EcobeeSensor(Entity):
"""Return the unit of measurement this sensor expresses itself in.""" """Return the unit of measurement this sensor expresses itself in."""
return self._unit_of_measurement return self._unit_of_measurement
def update(self): async def async_update(self):
"""Get the latest state of the sensor.""" """Get the latest state of the sensor."""
data = ecobee.NETWORK await self.data.update()
data.update() for sensor in self.data.ecobee.get_remote_sensors(self.index):
for sensor in data.ecobee.get_remote_sensors(self.index):
for item in sensor["capability"]: for item in sensor["capability"]:
if item["type"] == self.type and self.sensor_name == sensor["name"]: 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"]

View File

@ -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

View File

@ -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."
}
}
}

View File

@ -1,7 +1,8 @@
"""Support for displaying weather info from Ecobee API.""" """Support for displaying weather info from Ecobee API."""
from datetime import datetime from datetime import datetime
from homeassistant.components import ecobee from pyecobee.const import ECOBEE_STATE_UNKNOWN
from homeassistant.components.weather import ( from homeassistant.components.weather import (
ATTR_FORECAST_CONDITION, ATTR_FORECAST_CONDITION,
ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP,
@ -12,33 +13,37 @@ from homeassistant.components.weather import (
) )
from homeassistant.const import TEMP_FAHRENHEIT from homeassistant.const import TEMP_FAHRENHEIT
from .const import DOMAIN
ATTR_FORECAST_TEMP_HIGH = "temphigh" ATTR_FORECAST_TEMP_HIGH = "temphigh"
ATTR_FORECAST_PRESSURE = "pressure" ATTR_FORECAST_PRESSURE = "pressure"
ATTR_FORECAST_VISIBILITY = "visibility" ATTR_FORECAST_VISIBILITY = "visibility"
ATTR_FORECAST_HUMIDITY = "humidity" 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): async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Ecobee weather platform.""" """Set up the ecobee weather platform."""
if discovery_info is None: data = hass.data[DOMAIN]
return
dev = list() dev = list()
data = ecobee.NETWORK
for index in range(len(data.ecobee.thermostats)): for index in range(len(data.ecobee.thermostats)):
thermostat = data.ecobee.get_thermostat(index) thermostat = data.ecobee.get_thermostat(index)
if "weather" in thermostat: 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): class EcobeeWeather(WeatherEntity):
"""Representation of Ecobee weather data.""" """Representation of Ecobee weather data."""
def __init__(self, name, index): def __init__(self, data, name, index):
"""Initialize the Ecobee weather platform.""" """Initialize the Ecobee weather platform."""
self.data = data
self._name = name self._name = name
self._index = index self._index = index
self.weather = None self.weather = None
@ -140,26 +145,25 @@ class EcobeeWeather(WeatherEntity):
ATTR_FORECAST_CONDITION: day["condition"], ATTR_FORECAST_CONDITION: day["condition"],
ATTR_FORECAST_TEMP: float(day["tempHigh"]) / 10, ATTR_FORECAST_TEMP: float(day["tempHigh"]) / 10,
} }
if day["tempHigh"] == MISSING_DATA: if day["tempHigh"] == ECOBEE_STATE_UNKNOWN:
break break
if day["tempLow"] != MISSING_DATA: if day["tempLow"] != ECOBEE_STATE_UNKNOWN:
forecast[ATTR_FORECAST_TEMP_LOW] = float(day["tempLow"]) / 10 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"]) 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"]) 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"]) 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"]) forecast[ATTR_FORECAST_HUMIDITY] = int(day["relativeHumidity"])
forecasts.append(forecast) forecasts.append(forecast)
return forecasts return forecasts
except (ValueError, IndexError, KeyError): except (ValueError, IndexError, KeyError):
return None return None
def update(self): async def async_update(self):
"""Get the latest state of the sensor.""" """Get the latest weather data."""
data = ecobee.NETWORK await self.data.update()
data.update() thermostat = self.data.ecobee.get_thermostat(self._index)
thermostat = data.ecobee.get_thermostat(self._index)
self.weather = thermostat.get("weather", None) self.weather = thermostat.get("weather", None)

View File

@ -15,6 +15,7 @@ FLOWS = [
"daikin", "daikin",
"deconz", "deconz",
"dialogflow", "dialogflow",
"ecobee",
"emulated_roku", "emulated_roku",
"esphome", "esphome",
"geofency", "geofency",

View File

@ -1483,7 +1483,7 @@ python-clementine-remote==1.0.1
python-digitalocean==1.13.2 python-digitalocean==1.13.2
# homeassistant.components.ecobee # homeassistant.components.ecobee
python-ecobee-api==0.0.21 python-ecobee-api==0.1.2
# homeassistant.components.eq3btsmart # homeassistant.components.eq3btsmart
# python-eq3bt==0.1.9 # python-eq3bt==0.1.9

View File

@ -355,6 +355,9 @@ pysonos==0.0.23
# homeassistant.components.spc # homeassistant.components.spc
pyspcwebgw==0.4.0 pyspcwebgw==0.4.0
# homeassistant.components.ecobee
python-ecobee-api==0.1.2
# homeassistant.components.darksky # homeassistant.components.darksky
python-forecastio==1.4.0 python-forecastio==1.4.0

View File

@ -146,6 +146,7 @@ TEST_REQUIREMENTS = (
"pysonos", "pysonos",
"pyspcwebgw", "pyspcwebgw",
"python_awair", "python_awair",
"python-ecobee-api",
"python-forecastio", "python-forecastio",
"python-izone", "python-izone",
"python-nest", "python-nest",

View File

@ -54,7 +54,7 @@ class TestEcobee(unittest.TestCase):
self.data = mock.Mock() self.data = mock.Mock()
self.data.ecobee.get_thermostat.return_value = self.ecobee 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): def test_name(self):
"""Test name property.""" """Test name property."""

View File

@ -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"}
)