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

View File

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

View File

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

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."""
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))

View File

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

View File

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

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",
"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"]
}

View File

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

View File

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

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."""
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)

View File

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

View File

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

View File

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

View File

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

View File

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

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