From e317e0798b3814f9adf4ac514f0b04c20b0da9ff Mon Sep 17 00:00:00 2001 From: Nolan Gilley Date: Tue, 17 Nov 2015 19:14:29 -0500 Subject: [PATCH 01/12] initial commit for ecobee thermostat component. --- homeassistant/components/sensor/ecobee.py | 101 +++++++ homeassistant/components/thermostat/ecobee.py | 263 ++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 367 insertions(+) create mode 100644 homeassistant/components/sensor/ecobee.py create mode 100644 homeassistant/components/thermostat/ecobee.py diff --git a/homeassistant/components/sensor/ecobee.py b/homeassistant/components/sensor/ecobee.py new file mode 100644 index 00000000000..ba4db55b842 --- /dev/null +++ b/homeassistant/components/sensor/ecobee.py @@ -0,0 +1,101 @@ +""" +homeassistant.components.sensor.ecobee +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +This sensor component requires that the Ecobee Thermostat +component be setup first. This component shows remote +ecobee sensor data. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.ecobee/ +""" +from homeassistant.helpers.entity import Entity +import json +import logging +import os + +SENSOR_TYPES = { + 'temperature': ['Temperature', '°F'], + 'humidity': ['Humidity', '%'], + 'occupancy': ['Occupancy', ''] +} + +_LOGGER = logging.getLogger(__name__) + +ECOBEE_CONFIG_FILE = 'ecobee.conf' + + +def config_from_file(filename, config=None): + ''' Small configuration file management function ''' + if config: + # We're writing configuration + try: + with open(filename, 'w') as fdesc: + fdesc.write(json.dumps(config)) + except IOError as error: + print(error) + return False + return True + else: + # We're reading config + if os.path.isfile(filename): + try: + with open(filename, 'r') as fdesc: + return json.loads(fdesc.read()) + except IOError as error: + return False + else: + return {} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the sensors. """ + config = config_from_file(hass.config.path(ECOBEE_CONFIG_FILE)) + dev = list() + for name, data in config['sensors'].items(): + if 'temp' in data: + dev.append(EcobeeSensor(name, 'temperature', hass)) + if 'humidity' in data: + dev.append(EcobeeSensor(name, 'humidity', hass)) + if 'occupancy' in data: + dev.append(EcobeeSensor(name, 'occupancy', hass)) + + add_devices(dev) + + +class EcobeeSensor(Entity): + """ An ecobee sensor. """ + + def __init__(self, sensor_name, sensor_type, hass): + self._name = sensor_name + ' ' + SENSOR_TYPES[sensor_type][0] + self.sensor_name = sensor_name + self.hass = hass + self.type = sensor_type + self._state = None + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self.update() + + @property + def name(self): + return self._name.rstrip() + + @property + def state(self): + """ Returns the state of the device. """ + return self._state + + @property + def unit_of_measurement(self): + return self._unit_of_measurement + + def update(self): + config = config_from_file(self.hass.config.path(ECOBEE_CONFIG_FILE)) + try: + data = config['sensors'][self.sensor_name] + if self.type == 'temperature': + self._state = data['temp'] + elif self.type == 'humidity': + self._state = data['humidity'] + elif self.type == 'occupancy': + self._state = data['occupancy'] + except KeyError: + print("Error updating ecobee sensors.") diff --git a/homeassistant/components/thermostat/ecobee.py b/homeassistant/components/thermostat/ecobee.py new file mode 100644 index 00000000000..6792c34a421 --- /dev/null +++ b/homeassistant/components/thermostat/ecobee.py @@ -0,0 +1,263 @@ +#!/usr/local/bin/python3 +""" +homeassistant.components.thermostat.ecobee +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Ecobee Thermostat Component + +This component adds support for Ecobee3 Wireless Thermostats. +You will need to setup developer access to your thermostat, +and create and API key on the ecobee website. + +The first time you run this component you will see a configuration +component card in Home Assistant. This card will contain a PIN code +that you will need to use to authorize access to your thermostat. You +can do this at https://www.ecobee.com/consumerportal/index.html +Click My Apps, Add application, Enter Pin and click Authorize. + +After authorizing the application click the button in the configuration +card. Now your thermostat should shown in home-assistant. You will need +to restart home assistant to get rid of the configuration card. Once the +thermostat has been added you can add the ecobee sensor component +to your configuration.yaml. + +thermostat: + platform: ecobee + api_key: asdfasdfasdfasdfasdfaasdfasdfasdfasdf +""" +from homeassistant.loader import get_component +from homeassistant.components.thermostat import (ThermostatDevice, STATE_COOL, + STATE_IDLE, STATE_HEAT) +from homeassistant.const import ( + CONF_API_KEY, TEMP_FAHRENHEIT, STATE_ON, STATE_OFF) +import logging +import os + +REQUIREMENTS = [ + 'https://github.com/nkgilley/home-assistant-ecobee-api/archive/' + 'c61ee6d456bb5f4ab0c9598804aa9231c3d06f8e.zip#python-ecobee==0.1.1'] + +_LOGGER = logging.getLogger(__name__) + +ECOBEE_CONFIG_FILE = 'ecobee.conf' +_CONFIGURING = {} + + +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """ Setup Platform """ + # Only act if we are not already configuring this host + if 'ecobee' in _CONFIGURING: + return + + setup_ecobee(hass, config, add_devices_callback) + + +def setup_ecobee(hass, config, add_devices_callback): + """ Setup ecobee thermostat """ + from pyecobee import Ecobee, config_from_file + # Create ecobee.conf if it doesn't exist + if not os.path.isfile(hass.config.path(ECOBEE_CONFIG_FILE)): + jsonconfig = {"API_KEY": config[CONF_API_KEY]} + config_from_file(hass.config.path(ECOBEE_CONFIG_FILE), jsonconfig) + + ecobee = Ecobee(hass.config.path(ECOBEE_CONFIG_FILE)) + + # If ecobee has a PIN then it needs to be configured. + if ecobee.pin is not None: + # ecobee.request_pin() + request_configuration(ecobee, hass, add_devices_callback) + return + + if 'ecobee' in _CONFIGURING: + _CONFIGURING.pop('ecobee') + configurator = get_component('configurator') + configurator.request_done('ecobee') + + devices = [] + for index in range(0, len(ecobee.thermostats)): + devices.append(Thermostat(ecobee, index)) + + add_devices_callback(devices) + + +def request_configuration(ecobee, hass, add_devices_callback): + """ Request configuration steps from the user. """ + configurator = get_component('configurator') + if 'ecobee' in _CONFIGURING: + configurator.notify_errors( + _CONFIGURING['ecobee'], "Failed to register, please try again.") + + return + + # pylint: disable=unused-argument + def ecobee_configuration_callback(data): + """ Actions to do when our configuration callback is called. """ + ecobee.request_tokens() + ecobee.update() + setup_ecobee(hass, None, add_devices_callback) + + _CONFIGURING['ecobee'] = configurator.request_config( + hass, "Ecobee", ecobee_configuration_callback, + description=( + 'Please authorize this app at https://www.ecobee.com/consumer' + 'portal/index.html with pin code: ' + ecobee.pin), + description_image='https://goo.gl/6tBkbH', + submit_caption="I have authorized the app." + ) + + +class Thermostat(ThermostatDevice): + """docstring for Thermostat""" + + def __init__(self, ecobee, thermostat_index): + self.ecobee = ecobee + self.thermostat_index = thermostat_index + self.thermostat_data = self.ecobee.get_thermostat( + self.thermostat_index) + self._name = self.thermostat_data['name'] + if 'away' in self.thermostat_data['program']['currentClimateRef']: + self._away = True + else: + self._away = False + + def update(self): + self.thermostat_data = self.ecobee.get_thermostat( + self.thermostat_index) + _LOGGER.info("ecobee data updated successfully.") + + @property + def name(self): + """ Returns the name of the Ecobee Thermostat. """ + return self.thermostat_data['name'] + + @property + def unit_of_measurement(self): + """ Unit of measurement this thermostat expresses itself in. """ + return TEMP_FAHRENHEIT + + @property + def current_temperature(self): + """ Returns the current temperature. """ + return self.thermostat_data['runtime']['actualTemperature'] / 10 + + @property + def target_temperature(self): + """ Returns the temperature we try to reach. """ + return (self.target_temperature_low + self.target_temperature_high) / 2 + + @property + def target_temperature_low(self): + """ Returns the lower bound temperature we try to reach. """ + return int(self.thermostat_data['runtime']['desiredHeat'] / 10) + + @property + def target_temperature_high(self): + """ Returns the upper bound temperature we try to reach. """ + return int(self.thermostat_data['runtime']['desiredCool'] / 10) + + @property + def humidity(self): + """ Returns the current humidity. """ + return self.thermostat_data['runtime']['actualHumidity'] + + @property + def desired_fan_mode(self): + """ Returns the desired fan mode of operation. """ + return self.thermostat_data['runtime']['desiredFanMode'] + + @property + def fan(self): + """ Returns the current fan state. """ + if 'fan' in self.thermostat_data['equipmentStatus']: + return STATE_ON + else: + return STATE_OFF + + @property + def operation(self): + """ Returns current operation ie. heat, cool, idle """ + status = self.thermostat_data['equipmentStatus'] + if status == '': + return STATE_IDLE + elif 'Cool' in status: + return STATE_COOL + elif 'auxHeat' in status: + return STATE_HEAT + elif 'heatPump' in status: + return STATE_HEAT + else: + return status + + @property + def mode(self): + """ Returns current mode ie. home, away, sleep """ + mode = self.thermostat_data['program']['currentClimateRef'] + if 'away' in mode: + self._away = True + else: + self._away = False + return mode + + @property + def hvac_mode(self): + """ Return current hvac mode ie. auto, auxHeatOnly, cool, heat, off """ + return self.thermostat_data['settings']['hvacMode'] + + @property + def device_state_attributes(self): + """ Returns device specific state attributes. """ + # Move these to Thermostat Device and make them global + return { + "humidity": self.humidity, + "fan": self.fan, + "mode": self.mode, + "hvac_mode": self.hvac_mode + } + + @property + def is_away_mode_on(self): + """ Returns if away mode is on. """ + return self._away + + def turn_away_mode_on(self): + """ Turns away on. """ + self._away = True + self.ecobee.set_climate_hold("away") + + def turn_away_mode_off(self): + """ Turns away off. """ + self._away = False + self.ecobee.resume_program() + + def set_temperature(self, temperature): + """ Set new target temperature """ + temperature = int(temperature) + low_temp = temperature - 1 + high_temp = temperature + 1 + self.ecobee.set_hold_temp(low_temp, high_temp) + + def set_hvac_mode(self, mode): + """ Set HVAC mode (auto, auxHeatOnly, cool, heat, off) """ + self.ecobee.set_hvac_mode(mode) + + # Home and Sleep mode aren't used in UI yet: + + # def turn_home_mode_on(self): + # """ Turns home mode on. """ + # self._away = False + # self.ecobee.set_climate_hold("home") + + # def turn_home_mode_off(self): + # """ Turns home mode off. """ + # self._away = False + # self.ecobee.resume_program() + + # def turn_sleep_mode_on(self): + # """ Turns sleep mode on. """ + # self._away = False + # self.ecobee.set_climate_hold("sleep") + + # def turn_sleep_mode_off(self): + # """ Turns sleep mode off. """ + # self._away = False + # self.ecobee.resume_program() diff --git a/requirements_all.txt b/requirements_all.txt index ce6cbfabc96..db7ca1ada2d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -159,3 +159,6 @@ pushetta==1.0.15 # Orvibo S10 orvibo==1.0.0 + +# Ecobee (*.ecobee) +https://github.com/nkgilley/home-assistant-ecobee-api/archive/e0388659a0f2fc7266485affbd398350cc0b5c58.zip#python-ecobee==0.1.1 From 22fcbc67cfa61ca8f372a4ce978c687c19d5346d Mon Sep 17 00:00:00 2001 From: Nolan Gilley Date: Tue, 17 Nov 2015 19:20:56 -0500 Subject: [PATCH 02/12] fix req --- homeassistant/components/thermostat/ecobee.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/thermostat/ecobee.py b/homeassistant/components/thermostat/ecobee.py index 6792c34a421..cbdd52ba7e9 100644 --- a/homeassistant/components/thermostat/ecobee.py +++ b/homeassistant/components/thermostat/ecobee.py @@ -35,7 +35,7 @@ import os REQUIREMENTS = [ 'https://github.com/nkgilley/home-assistant-ecobee-api/archive/' - 'c61ee6d456bb5f4ab0c9598804aa9231c3d06f8e.zip#python-ecobee==0.1.1'] + 'e0388659a0f2fc7266485affbd398350cc0b5c58.zip#python-ecobee==0.1.1'] _LOGGER = logging.getLogger(__name__) From c6d1a4bdaf572407cfbfe79dd5a259c6290a3780 Mon Sep 17 00:00:00 2001 From: "nkgilley@gmail.com" Date: Wed, 18 Nov 2015 10:13:46 -0500 Subject: [PATCH 03/12] Fix configurator, rename repo, cleanup code. --- homeassistant/components/sensor/ecobee.py | 26 ++++----- homeassistant/components/thermostat/ecobee.py | 53 ++++++++----------- requirements_all.txt | 2 +- 3 files changed, 32 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/sensor/ecobee.py b/homeassistant/components/sensor/ecobee.py index ba4db55b842..a8d9e41acb1 100644 --- a/homeassistant/components/sensor/ecobee.py +++ b/homeassistant/components/sensor/ecobee.py @@ -13,6 +13,8 @@ import json import logging import os +DEPENDENCIES = ['thermostat'] + SENSOR_TYPES = { 'temperature': ['Temperature', '°F'], 'humidity': ['Humidity', '%'], @@ -24,27 +26,17 @@ _LOGGER = logging.getLogger(__name__) ECOBEE_CONFIG_FILE = 'ecobee.conf' -def config_from_file(filename, config=None): - ''' Small configuration file management function ''' - if config: - # We're writing configuration +def config_from_file(filename): + ''' Small configuration file reading function ''' + if os.path.isfile(filename): try: - with open(filename, 'w') as fdesc: - fdesc.write(json.dumps(config)) + with open(filename, 'r') as fdesc: + return json.loads(fdesc.read()) except IOError as error: - print(error) + _LOGGER.error("ecobee sensor couldn't read config file: " + error) return False - return True else: - # We're reading config - if os.path.isfile(filename): - try: - with open(filename, 'r') as fdesc: - return json.loads(fdesc.read()) - except IOError as error: - return False - else: - return {} + return {} def setup_platform(hass, config, add_devices, discovery_info=None): diff --git a/homeassistant/components/thermostat/ecobee.py b/homeassistant/components/thermostat/ecobee.py index cbdd52ba7e9..0c00fb0de46 100644 --- a/homeassistant/components/thermostat/ecobee.py +++ b/homeassistant/components/thermostat/ecobee.py @@ -16,8 +16,7 @@ can do this at https://www.ecobee.com/consumerportal/index.html Click My Apps, Add application, Enter Pin and click Authorize. After authorizing the application click the button in the configuration -card. Now your thermostat should shown in home-assistant. You will need -to restart home assistant to get rid of the configuration card. Once the +card. Now your thermostat should shown in home-assistant. Once the thermostat has been added you can add the ecobee sensor component to your configuration.yaml. @@ -34,8 +33,8 @@ import logging import os REQUIREMENTS = [ - 'https://github.com/nkgilley/home-assistant-ecobee-api/archive/' - 'e0388659a0f2fc7266485affbd398350cc0b5c58.zip#python-ecobee==0.1.1'] + 'https://github.com/nkgilley/python-ecobee-api/archive/' + '824a7dfabe7ef6975b2864f33e6ae0b48fb6ea3f.zip#python-ecobee==0.0.1'] _LOGGER = logging.getLogger(__name__) @@ -64,20 +63,15 @@ def setup_ecobee(hass, config, add_devices_callback): # If ecobee has a PIN then it needs to be configured. if ecobee.pin is not None: - # ecobee.request_pin() request_configuration(ecobee, hass, add_devices_callback) return if 'ecobee' in _CONFIGURING: - _CONFIGURING.pop('ecobee') configurator = get_component('configurator') - configurator.request_done('ecobee') + configurator.request_done(_CONFIGURING.pop('ecobee')) - devices = [] - for index in range(0, len(ecobee.thermostats)): - devices.append(Thermostat(ecobee, index)) - - add_devices_callback(devices) + add_devices_callback(Thermostat(ecobee, index) + for index in range(len(ecobee.thermostats))) def request_configuration(ecobee, hass, add_devices_callback): @@ -101,34 +95,31 @@ def request_configuration(ecobee, hass, add_devices_callback): description=( 'Please authorize this app at https://www.ecobee.com/consumer' 'portal/index.html with pin code: ' + ecobee.pin), - description_image='https://goo.gl/6tBkbH', + description_image="/static/images/config_ecobee_thermostat.png", submit_caption="I have authorized the app." ) class Thermostat(ThermostatDevice): - """docstring for Thermostat""" + """ Thermostat class for Ecobee """ def __init__(self, ecobee, thermostat_index): self.ecobee = ecobee self.thermostat_index = thermostat_index - self.thermostat_data = self.ecobee.get_thermostat( + self.thermostat = self.ecobee.get_thermostat( self.thermostat_index) - self._name = self.thermostat_data['name'] - if 'away' in self.thermostat_data['program']['currentClimateRef']: - self._away = True - else: - self._away = False + self._name = self.thermostat['name'] + self._away = 'away' in self.thermostat['program']['currentClimateRef'] def update(self): - self.thermostat_data = self.ecobee.get_thermostat( + self.thermostat = self.ecobee.get_thermostat( self.thermostat_index) _LOGGER.info("ecobee data updated successfully.") @property def name(self): """ Returns the name of the Ecobee Thermostat. """ - return self.thermostat_data['name'] + return self.thermostat['name'] @property def unit_of_measurement(self): @@ -138,7 +129,7 @@ class Thermostat(ThermostatDevice): @property def current_temperature(self): """ Returns the current temperature. """ - return self.thermostat_data['runtime']['actualTemperature'] / 10 + return self.thermostat['runtime']['actualTemperature'] / 10 @property def target_temperature(self): @@ -148,27 +139,27 @@ class Thermostat(ThermostatDevice): @property def target_temperature_low(self): """ Returns the lower bound temperature we try to reach. """ - return int(self.thermostat_data['runtime']['desiredHeat'] / 10) + return int(self.thermostat['runtime']['desiredHeat'] / 10) @property def target_temperature_high(self): """ Returns the upper bound temperature we try to reach. """ - return int(self.thermostat_data['runtime']['desiredCool'] / 10) + return int(self.thermostat['runtime']['desiredCool'] / 10) @property def humidity(self): """ Returns the current humidity. """ - return self.thermostat_data['runtime']['actualHumidity'] + return self.thermostat['runtime']['actualHumidity'] @property def desired_fan_mode(self): """ Returns the desired fan mode of operation. """ - return self.thermostat_data['runtime']['desiredFanMode'] + return self.thermostat['runtime']['desiredFanMode'] @property def fan(self): """ Returns the current fan state. """ - if 'fan' in self.thermostat_data['equipmentStatus']: + if 'fan' in self.thermostat['equipmentStatus']: return STATE_ON else: return STATE_OFF @@ -176,7 +167,7 @@ class Thermostat(ThermostatDevice): @property def operation(self): """ Returns current operation ie. heat, cool, idle """ - status = self.thermostat_data['equipmentStatus'] + status = self.thermostat['equipmentStatus'] if status == '': return STATE_IDLE elif 'Cool' in status: @@ -191,7 +182,7 @@ class Thermostat(ThermostatDevice): @property def mode(self): """ Returns current mode ie. home, away, sleep """ - mode = self.thermostat_data['program']['currentClimateRef'] + mode = self.thermostat['program']['currentClimateRef'] if 'away' in mode: self._away = True else: @@ -201,7 +192,7 @@ class Thermostat(ThermostatDevice): @property def hvac_mode(self): """ Return current hvac mode ie. auto, auxHeatOnly, cool, heat, off """ - return self.thermostat_data['settings']['hvacMode'] + return self.thermostat['settings']['hvacMode'] @property def device_state_attributes(self): diff --git a/requirements_all.txt b/requirements_all.txt index db7ca1ada2d..4e34b6029ac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -161,4 +161,4 @@ pushetta==1.0.15 orvibo==1.0.0 # Ecobee (*.ecobee) -https://github.com/nkgilley/home-assistant-ecobee-api/archive/e0388659a0f2fc7266485affbd398350cc0b5c58.zip#python-ecobee==0.1.1 +https://github.com/nkgilley/python-ecobee-api/archive/824a7dfabe7ef6975b2864f33e6ae0b48fb6ea3f.zip#python-ecobee==0.0.1 From 18d0f4461f0fade2e919f16ae18c826932b9e749 Mon Sep 17 00:00:00 2001 From: "nkgilley@gmail.com" Date: Wed, 18 Nov 2015 10:16:16 -0500 Subject: [PATCH 04/12] add config png to images dir. --- .../images/config_ecobee_thermostat.png | Bin 0 -> 31083 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 homeassistant/components/frontend/www_static/images/config_ecobee_thermostat.png diff --git a/homeassistant/components/frontend/www_static/images/config_ecobee_thermostat.png b/homeassistant/components/frontend/www_static/images/config_ecobee_thermostat.png new file mode 100644 index 0000000000000000000000000000000000000000..e62a4165c9becb8c974faa54523cd1bad9b4ef32 GIT binary patch literal 31083 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 Date: Wed, 18 Nov 2015 10:43:52 -0500 Subject: [PATCH 05/12] point to updated python-ecobee library --- homeassistant/components/thermostat/ecobee.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/thermostat/ecobee.py b/homeassistant/components/thermostat/ecobee.py index 0c00fb0de46..472c0e60b60 100644 --- a/homeassistant/components/thermostat/ecobee.py +++ b/homeassistant/components/thermostat/ecobee.py @@ -34,7 +34,7 @@ import os REQUIREMENTS = [ 'https://github.com/nkgilley/python-ecobee-api/archive/' - '824a7dfabe7ef6975b2864f33e6ae0b48fb6ea3f.zip#python-ecobee==0.0.1'] + '730009b9593899d42e98c81a0544f91e65b2bc15.zip#python-ecobee==0.0.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 4e34b6029ac..3a930c145dd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -161,4 +161,4 @@ pushetta==1.0.15 orvibo==1.0.0 # Ecobee (*.ecobee) -https://github.com/nkgilley/python-ecobee-api/archive/824a7dfabe7ef6975b2864f33e6ae0b48fb6ea3f.zip#python-ecobee==0.0.1 +https://github.com/nkgilley/python-ecobee-api/archive/730009b9593899d42e98c81a0544f91e65b2bc15.zip#python-ecobee==0.0.1 From d05af626802231bf1398298a5e1cb9d0753a1820 Mon Sep 17 00:00:00 2001 From: "nkgilley@gmail.com" Date: Wed, 18 Nov 2015 14:57:27 -0500 Subject: [PATCH 06/12] use Throttle like the BitCoin component. --- .coveragerc | 1 + homeassistant/components/thermostat/ecobee.py | 82 +++++++++++-------- 2 files changed, 50 insertions(+), 33 deletions(-) diff --git a/.coveragerc b/.coveragerc index f19e37d00a1..9fafe443dcf 100644 --- a/.coveragerc +++ b/.coveragerc @@ -16,6 +16,7 @@ omit = homeassistant/components/*/tellstick.py homeassistant/components/*/vera.py + homeassistant/components/*/ecobee.py homeassistant/components/verisure.py homeassistant/components/*/verisure.py diff --git a/homeassistant/components/thermostat/ecobee.py b/homeassistant/components/thermostat/ecobee.py index 472c0e60b60..7258a3b65a1 100644 --- a/homeassistant/components/thermostat/ecobee.py +++ b/homeassistant/components/thermostat/ecobee.py @@ -29,18 +29,23 @@ from homeassistant.components.thermostat import (ThermostatDevice, STATE_COOL, STATE_IDLE, STATE_HEAT) from homeassistant.const import ( CONF_API_KEY, TEMP_FAHRENHEIT, STATE_ON, STATE_OFF) +from homeassistant.util import Throttle +from datetime import timedelta import logging import os REQUIREMENTS = [ 'https://github.com/nkgilley/python-ecobee-api/archive/' - '730009b9593899d42e98c81a0544f91e65b2bc15.zip#python-ecobee==0.0.1'] + '790c20d820dbb727af2dbfb3ef0f79231e19a503.zip#python-ecobee==0.0.1'] _LOGGER = logging.getLogger(__name__) ECOBEE_CONFIG_FILE = 'ecobee.conf' _CONFIGURING = {} +# Return cached results if last scan was less then this time ago +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180) + def setup_platform(hass, config, add_devices_callback, discovery_info=None): """ Setup Platform """ @@ -48,33 +53,32 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): if 'ecobee' in _CONFIGURING: return - setup_ecobee(hass, config, add_devices_callback) + from pyecobee import config_from_file - -def setup_ecobee(hass, config, add_devices_callback): - """ Setup ecobee thermostat """ - from pyecobee import Ecobee, config_from_file # Create ecobee.conf if it doesn't exist if not os.path.isfile(hass.config.path(ECOBEE_CONFIG_FILE)): jsonconfig = {"API_KEY": config[CONF_API_KEY]} config_from_file(hass.config.path(ECOBEE_CONFIG_FILE), jsonconfig) + data = EcobeeData(hass.config.path(ECOBEE_CONFIG_FILE)) + setup_ecobee(hass, data, config, add_devices_callback) - ecobee = Ecobee(hass.config.path(ECOBEE_CONFIG_FILE)) +def setup_ecobee(hass, data, config, add_devices_callback): + """ Setup ecobee thermostat """ # If ecobee has a PIN then it needs to be configured. - if ecobee.pin is not None: - request_configuration(ecobee, hass, add_devices_callback) + if data.ecobee.pin is not None: + request_configuration(data, hass, add_devices_callback) return if 'ecobee' in _CONFIGURING: configurator = get_component('configurator') configurator.request_done(_CONFIGURING.pop('ecobee')) - add_devices_callback(Thermostat(ecobee, index) - for index in range(len(ecobee.thermostats))) + add_devices_callback(Thermostat(data, index) + for index in range(len(data.ecobee.thermostats))) -def request_configuration(ecobee, hass, add_devices_callback): +def request_configuration(data, hass, add_devices_callback): """ Request configuration steps from the user. """ configurator = get_component('configurator') if 'ecobee' in _CONFIGURING: @@ -84,35 +88,50 @@ def request_configuration(ecobee, hass, add_devices_callback): return # pylint: disable=unused-argument - def ecobee_configuration_callback(data): + def ecobee_configuration_callback(callback_data): """ Actions to do when our configuration callback is called. """ - ecobee.request_tokens() - ecobee.update() - setup_ecobee(hass, None, add_devices_callback) + data.ecobee.request_tokens() + data.ecobee.update() + setup_ecobee(hass, data, None, add_devices_callback) _CONFIGURING['ecobee'] = configurator.request_config( hass, "Ecobee", ecobee_configuration_callback, description=( 'Please authorize this app at https://www.ecobee.com/consumer' - 'portal/index.html with pin code: ' + ecobee.pin), + 'portal/index.html with pin code: ' + data.ecobee.pin), description_image="/static/images/config_ecobee_thermostat.png", submit_caption="I have authorized the app." ) +# pylint: disable=too-few-public-methods +class EcobeeData(object): + """ Gets the latest data and update the states. """ + + def __init__(self, config_filename): + from pyecobee import Ecobee + self.ecobee = Ecobee(config_filename) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """ Get the latest data from pyecobee. """ + self.ecobee.update() + + class Thermostat(ThermostatDevice): """ Thermostat class for Ecobee """ - def __init__(self, ecobee, thermostat_index): - self.ecobee = ecobee + def __init__(self, data, thermostat_index): + self.data = data self.thermostat_index = thermostat_index - self.thermostat = self.ecobee.get_thermostat( + self.thermostat = self.data.ecobee.get_thermostat( self.thermostat_index) self._name = self.thermostat['name'] self._away = 'away' in self.thermostat['program']['currentClimateRef'] def update(self): - self.thermostat = self.ecobee.get_thermostat( + self.data.update() + self.thermostat = self.data.ecobee.get_thermostat( self.thermostat_index) _LOGGER.info("ecobee data updated successfully.") @@ -183,10 +202,7 @@ class Thermostat(ThermostatDevice): def mode(self): """ Returns current mode ie. home, away, sleep """ mode = self.thermostat['program']['currentClimateRef'] - if 'away' in mode: - self._away = True - else: - self._away = False + self._away = 'away' in mode return mode @property @@ -213,42 +229,42 @@ class Thermostat(ThermostatDevice): def turn_away_mode_on(self): """ Turns away on. """ self._away = True - self.ecobee.set_climate_hold("away") + self.data.ecobee.set_climate_hold("away") def turn_away_mode_off(self): """ Turns away off. """ self._away = False - self.ecobee.resume_program() + self.data.ecobee.resume_program() def set_temperature(self, temperature): """ Set new target temperature """ temperature = int(temperature) low_temp = temperature - 1 high_temp = temperature + 1 - self.ecobee.set_hold_temp(low_temp, high_temp) + self.data.ecobee.set_hold_temp(low_temp, high_temp) def set_hvac_mode(self, mode): """ Set HVAC mode (auto, auxHeatOnly, cool, heat, off) """ - self.ecobee.set_hvac_mode(mode) + self.data.ecobee.set_hvac_mode(mode) # Home and Sleep mode aren't used in UI yet: # def turn_home_mode_on(self): # """ Turns home mode on. """ # self._away = False - # self.ecobee.set_climate_hold("home") + # self.data.ecobee.set_climate_hold("home") # def turn_home_mode_off(self): # """ Turns home mode off. """ # self._away = False - # self.ecobee.resume_program() + # self.data.ecobee.resume_program() # def turn_sleep_mode_on(self): # """ Turns sleep mode on. """ # self._away = False - # self.ecobee.set_climate_hold("sleep") + # self.data.ecobee.set_climate_hold("sleep") # def turn_sleep_mode_off(self): # """ Turns sleep mode off. """ # self._away = False - # self.ecobee.resume_program() + # self.data.ecobee.resume_program() From 44abc31057c174968939beb940d48e55b326895c Mon Sep 17 00:00:00 2001 From: "nkgilley@gmail.com" Date: Fri, 20 Nov 2015 17:47:25 -0500 Subject: [PATCH 07/12] work in progress: configurator is now in it's own component. configurator seems to work but the thermostat is now broken. --- .coveragerc | 2 + homeassistant/components/ecobee.py | 127 ++++++++++++++++++ homeassistant/components/thermostat/ecobee.py | 72 ++-------- requirements_all.txt | 2 +- 4 files changed, 142 insertions(+), 61 deletions(-) create mode 100644 homeassistant/components/ecobee.py diff --git a/.coveragerc b/.coveragerc index 9fafe443dcf..b8016fa7624 100644 --- a/.coveragerc +++ b/.coveragerc @@ -16,6 +16,8 @@ omit = homeassistant/components/*/tellstick.py homeassistant/components/*/vera.py + + homeassistant/components/ecobee.py homeassistant/components/*/ecobee.py homeassistant/components/verisure.py diff --git a/homeassistant/components/ecobee.py b/homeassistant/components/ecobee.py new file mode 100644 index 00000000000..99db73b6a90 --- /dev/null +++ b/homeassistant/components/ecobee.py @@ -0,0 +1,127 @@ +""" +homeassistant.components.zwave +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Connects Home Assistant to the Ecobee API and maintains tokens. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/ecobee/ + +[ecobee] +api_key: asdflaksf +""" + +from homeassistant.loader import get_component +from homeassistant import bootstrap +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, + EVENT_PLATFORM_DISCOVERED, ATTR_SERVICE, ATTR_DISCOVERED, CONF_API_KEY) +from datetime import timedelta +import logging +import os + +DOMAIN = "ecobee" +DISCOVER_THERMOSTAT = "ecobee.thermostat" +DEPENDENCIES = [] +NETWORK = None + +REQUIREMENTS = [ + 'https://github.com/nkgilley/python-ecobee-api/archive/' + 'd35596b67c75451fa47001c493a15eebee195e93.zip#python-ecobee==0.0.1'] + +_LOGGER = logging.getLogger(__name__) + +ECOBEE_CONFIG_FILE = 'ecobee.conf' +_CONFIGURING = {} + +# Return cached results if last scan was less then this time ago +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180) + + +def request_configuration(network, hass): + """ Request configuration steps from the user. """ + configurator = get_component('configurator') + if 'ecobee' in _CONFIGURING: + configurator.notify_errors( + _CONFIGURING['ecobee'], "Failed to register, please try again.") + + return + + # pylint: disable=unused-argument + def ecobee_configuration_callback(callback_data): + """ Actions to do when our configuration callback is called. """ + network.request_tokens() + network.update() + setup_ecobee(hass, network) + + _CONFIGURING['ecobee'] = configurator.request_config( + hass, "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." + ) + + +def setup_ecobee(hass, network): + """ Setup ecobee thermostat """ + # If ecobee has a PIN then it needs to be configured. + if network.pin is not None: + request_configuration(network, hass) + return + + if 'ecobee' in _CONFIGURING: + configurator = get_component('configurator') + configurator.request_done(_CONFIGURING.pop('ecobee')) + + +def setup(hass, config): + """ + Setup Ecobee. + Will automatically load thermostat and sensor components to support + devices discovered on the network. + """ + # pylint: disable=global-statement, import-error + global NETWORK + + if 'ecobee' in _CONFIGURING: + return + + from pyecobee import Ecobee, config_from_file + + # Create ecobee.conf if it doesn't exist + if not os.path.isfile(hass.config.path(ECOBEE_CONFIG_FILE)): + if config[DOMAIN].get(CONF_API_KEY) is None: + _LOGGER.error("No ecobee api_key found in config.") + return + jsonconfig = {"API_KEY": config[DOMAIN].get(CONF_API_KEY)} + config_from_file(hass.config.path(ECOBEE_CONFIG_FILE), jsonconfig) + + NETWORK = Ecobee(hass.config.path(ECOBEE_CONFIG_FILE)) + + setup_ecobee(hass, NETWORK) + + # Ensure component is loaded + bootstrap.setup_component(hass, 'thermostat', config) + + # Fire discovery event + hass.bus.fire(EVENT_PLATFORM_DISCOVERED, { + ATTR_SERVICE: DISCOVER_THERMOSTAT, + ATTR_DISCOVERED: { + 'network': NETWORK, + } + }) + + def stop_ecobee(event): + """ Stop Ecobee. """ + + pass + + def start_ecobee(event): + """ Called when Home Assistant starts up. """ + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_ecobee) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_ecobee) + + return True diff --git a/homeassistant/components/thermostat/ecobee.py b/homeassistant/components/thermostat/ecobee.py index 7258a3b65a1..5b377be4907 100644 --- a/homeassistant/components/thermostat/ecobee.py +++ b/homeassistant/components/thermostat/ecobee.py @@ -1,4 +1,3 @@ -#!/usr/local/bin/python3 """ homeassistant.components.thermostat.ecobee ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -24,19 +23,14 @@ thermostat: platform: ecobee api_key: asdfasdfasdfasdfasdfaasdfasdfasdfasdf """ -from homeassistant.loader import get_component from homeassistant.components.thermostat import (ThermostatDevice, STATE_COOL, STATE_IDLE, STATE_HEAT) -from homeassistant.const import ( - CONF_API_KEY, TEMP_FAHRENHEIT, STATE_ON, STATE_OFF) +from homeassistant.const import (TEMP_FAHRENHEIT, STATE_ON, STATE_OFF) from homeassistant.util import Throttle from datetime import timedelta import logging -import os -REQUIREMENTS = [ - 'https://github.com/nkgilley/python-ecobee-api/archive/' - '790c20d820dbb727af2dbfb3ef0f79231e19a503.zip#python-ecobee==0.0.1'] +DEPENDENCIES = ['ecobee'] _LOGGER = logging.getLogger(__name__) @@ -47,70 +41,28 @@ _CONFIGURING = {} MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180) -def setup_platform(hass, config, add_devices_callback, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """ Setup Platform """ - # Only act if we are not already configuring this host - if 'ecobee' in _CONFIGURING: + _LOGGER.error("ecobee !!!!") + if discovery_info is None: return - - from pyecobee import config_from_file - - # Create ecobee.conf if it doesn't exist - if not os.path.isfile(hass.config.path(ECOBEE_CONFIG_FILE)): - jsonconfig = {"API_KEY": config[CONF_API_KEY]} - config_from_file(hass.config.path(ECOBEE_CONFIG_FILE), jsonconfig) - data = EcobeeData(hass.config.path(ECOBEE_CONFIG_FILE)) - setup_ecobee(hass, data, config, add_devices_callback) + data = EcobeeData(discovery_info[0]) + setup_ecobee(hass, data, add_devices) -def setup_ecobee(hass, data, config, add_devices_callback): +def setup_ecobee(hass, data, add_devices): """ Setup ecobee thermostat """ - # If ecobee has a PIN then it needs to be configured. - if data.ecobee.pin is not None: - request_configuration(data, hass, add_devices_callback) - return - if 'ecobee' in _CONFIGURING: - configurator = get_component('configurator') - configurator.request_done(_CONFIGURING.pop('ecobee')) - - add_devices_callback(Thermostat(data, index) - for index in range(len(data.ecobee.thermostats))) - - -def request_configuration(data, hass, add_devices_callback): - """ Request configuration steps from the user. """ - configurator = get_component('configurator') - if 'ecobee' in _CONFIGURING: - configurator.notify_errors( - _CONFIGURING['ecobee'], "Failed to register, please try again.") - - return - - # pylint: disable=unused-argument - def ecobee_configuration_callback(callback_data): - """ Actions to do when our configuration callback is called. """ - data.ecobee.request_tokens() - data.ecobee.update() - setup_ecobee(hass, data, None, add_devices_callback) - - _CONFIGURING['ecobee'] = configurator.request_config( - hass, "Ecobee", ecobee_configuration_callback, - description=( - 'Please authorize this app at https://www.ecobee.com/consumer' - 'portal/index.html with pin code: ' + data.ecobee.pin), - description_image="/static/images/config_ecobee_thermostat.png", - submit_caption="I have authorized the app." - ) + add_devices(Thermostat(data, index) + for index in range(len(data.ecobee.thermostats))) # pylint: disable=too-few-public-methods class EcobeeData(object): """ Gets the latest data and update the states. """ - def __init__(self, config_filename): - from pyecobee import Ecobee - self.ecobee = Ecobee(config_filename) + def __init__(self, network): + self.ecobee = network @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): diff --git a/requirements_all.txt b/requirements_all.txt index 3a930c145dd..b74226f4991 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -161,4 +161,4 @@ pushetta==1.0.15 orvibo==1.0.0 # Ecobee (*.ecobee) -https://github.com/nkgilley/python-ecobee-api/archive/730009b9593899d42e98c81a0544f91e65b2bc15.zip#python-ecobee==0.0.1 +https://github.com/nkgilley/python-ecobee-api/archive/d35596b67c75451fa47001c493a15eebee195e93.zip#python-ecobee==0.0.1 From 8dc0de1d05cda0f218b882ccf4013bbfa405824d Mon Sep 17 00:00:00 2001 From: "nkgilley@gmail.com" Date: Sat, 21 Nov 2015 12:24:06 -0500 Subject: [PATCH 08/12] move EcobeeData class and Throttle to the main ecobee component, this way the sensor and thermostat will use the same throttled updating object. --- homeassistant/components/ecobee.py | 25 ++++++++++++++---- homeassistant/components/thermostat/ecobee.py | 26 +------------------ 2 files changed, 21 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/ecobee.py b/homeassistant/components/ecobee.py index 99db73b6a90..ef982a15e63 100644 --- a/homeassistant/components/ecobee.py +++ b/homeassistant/components/ecobee.py @@ -1,5 +1,5 @@ """ -homeassistant.components.zwave +homeassistant.components.ecobee ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Connects Home Assistant to the Ecobee API and maintains tokens. @@ -12,6 +12,7 @@ api_key: asdflaksf from homeassistant.loader import get_component from homeassistant import bootstrap +from homeassistant.util import Throttle from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_PLATFORM_DISCOVERED, ATTR_SERVICE, ATTR_DISCOVERED, CONF_API_KEY) @@ -57,7 +58,7 @@ def request_configuration(network, hass): hass, "Ecobee", ecobee_configuration_callback, description=( 'Please authorize this app at https://www.ecobee.com/consumer' - 'portal/index.html with pin code: ' + NETWORK.pin), + 'portal/index.html with pin code: ' + network.pin), description_image="/static/images/config_ecobee_thermostat.png", submit_caption="I have authorized the app." ) @@ -75,6 +76,20 @@ def setup_ecobee(hass, network): configurator.request_done(_CONFIGURING.pop('ecobee')) +# pylint: disable=too-few-public-methods +class EcobeeData(object): + """ Gets the latest data and update the states. """ + + def __init__(self, config_file): + from pyecobee import Ecobee + self.ecobee = Ecobee(config_file) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """ Get the latest data from pyecobee. """ + self.ecobee.update() + + def setup(hass, config): """ Setup Ecobee. @@ -87,7 +102,7 @@ def setup(hass, config): if 'ecobee' in _CONFIGURING: return - from pyecobee import Ecobee, config_from_file + from pyecobee import config_from_file # Create ecobee.conf if it doesn't exist if not os.path.isfile(hass.config.path(ECOBEE_CONFIG_FILE)): @@ -97,9 +112,9 @@ def setup(hass, config): jsonconfig = {"API_KEY": config[DOMAIN].get(CONF_API_KEY)} config_from_file(hass.config.path(ECOBEE_CONFIG_FILE), jsonconfig) - NETWORK = Ecobee(hass.config.path(ECOBEE_CONFIG_FILE)) + NETWORK = EcobeeData(hass.config.path(ECOBEE_CONFIG_FILE)) - setup_ecobee(hass, NETWORK) + setup_ecobee(hass, NETWORK.ecobee) # Ensure component is loaded bootstrap.setup_component(hass, 'thermostat', config) diff --git a/homeassistant/components/thermostat/ecobee.py b/homeassistant/components/thermostat/ecobee.py index 5b377be4907..7f95aa7d1c6 100644 --- a/homeassistant/components/thermostat/ecobee.py +++ b/homeassistant/components/thermostat/ecobee.py @@ -26,8 +26,6 @@ thermostat: from homeassistant.components.thermostat import (ThermostatDevice, STATE_COOL, STATE_IDLE, STATE_HEAT) from homeassistant.const import (TEMP_FAHRENHEIT, STATE_ON, STATE_OFF) -from homeassistant.util import Throttle -from datetime import timedelta import logging DEPENDENCIES = ['ecobee'] @@ -37,39 +35,17 @@ _LOGGER = logging.getLogger(__name__) ECOBEE_CONFIG_FILE = 'ecobee.conf' _CONFIGURING = {} -# Return cached results if last scan was less then this time ago -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180) - def setup_platform(hass, config, add_devices, discovery_info=None): """ Setup Platform """ _LOGGER.error("ecobee !!!!") if discovery_info is None: return - data = EcobeeData(discovery_info[0]) - setup_ecobee(hass, data, add_devices) - - -def setup_ecobee(hass, data, add_devices): - """ Setup ecobee thermostat """ - + data = discovery_info[0] add_devices(Thermostat(data, index) for index in range(len(data.ecobee.thermostats))) -# pylint: disable=too-few-public-methods -class EcobeeData(object): - """ Gets the latest data and update the states. """ - - def __init__(self, network): - self.ecobee = network - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """ Get the latest data from pyecobee. """ - self.ecobee.update() - - class Thermostat(ThermostatDevice): """ Thermostat class for Ecobee """ From cc196d988867fe7e7d08c062c3ca06bf3383bdcf Mon Sep 17 00:00:00 2001 From: "nkgilley@gmail.com" Date: Mon, 23 Nov 2015 11:15:19 -0500 Subject: [PATCH 09/12] fixed sensors and thermostat. discovery working for both now. --- homeassistant/components/ecobee.py | 70 ++++++++++++------ homeassistant/components/sensor/__init__.py | 5 +- homeassistant/components/sensor/ecobee.py | 73 ++++++++++--------- .../components/thermostat/__init__.py | 8 +- homeassistant/components/thermostat/ecobee.py | 35 ++++++--- 5 files changed, 118 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/ecobee.py b/homeassistant/components/ecobee.py index ef982a15e63..8b73a9969ef 100644 --- a/homeassistant/components/ecobee.py +++ b/homeassistant/components/ecobee.py @@ -1,13 +1,29 @@ """ homeassistant.components.ecobee -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Connects Home Assistant to the Ecobee API and maintains tokens. +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/ecobee/ +Ecobee Component + +This component adds support for Ecobee3 Wireless Thermostats. +You will need to setup developer access to your thermostat, +and create and API key on the ecobee website. + +The first time you run this component you will see a configuration +component card in Home Assistant. This card will contain a PIN code +that you will need to use to authorize access to your thermostat. You +can do this at https://www.ecobee.com/consumerportal/index.html +Click My Apps, Add application, Enter Pin and click Authorize. + +After authorizing the application click the button in the configuration +card. Now your thermostat and sensors should shown in home-assistant. + +You can use the optional hold_temp parameter to set whether or not holds +are set indefintely or until the next scheduled event. + +ecobee: + api_key: asdfasdfasdfasdfasdfaasdfasdfasdfasdf + hold_temp: True -[ecobee] -api_key: asdflaksf """ from homeassistant.loader import get_component @@ -22,8 +38,10 @@ import os DOMAIN = "ecobee" DISCOVER_THERMOSTAT = "ecobee.thermostat" +DISCOVER_SENSORS = "ecobee.sensor" DEPENDENCIES = [] NETWORK = None +HOLD_TEMP = 'hold_temp' REQUIREMENTS = [ 'https://github.com/nkgilley/python-ecobee-api/archive/' @@ -38,7 +56,7 @@ _CONFIGURING = {} MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180) -def request_configuration(network, hass): +def request_configuration(network, hass, config): """ Request configuration steps from the user. """ configurator = get_component('configurator') if 'ecobee' in _CONFIGURING: @@ -52,7 +70,7 @@ def request_configuration(network, hass): """ Actions to do when our configuration callback is called. """ network.request_tokens() network.update() - setup_ecobee(hass, network) + setup_ecobee(hass, network, config) _CONFIGURING['ecobee'] = configurator.request_config( hass, "Ecobee", ecobee_configuration_callback, @@ -64,17 +82,35 @@ def request_configuration(network, hass): ) -def setup_ecobee(hass, network): +def setup_ecobee(hass, network, config): """ Setup ecobee thermostat """ # If ecobee has a PIN then it needs to be configured. if network.pin is not None: - request_configuration(network, hass) + request_configuration(network, hass, config) return if 'ecobee' in _CONFIGURING: configurator = get_component('configurator') configurator.request_done(_CONFIGURING.pop('ecobee')) + # Ensure component is loaded + bootstrap.setup_component(hass, 'thermostat') + bootstrap.setup_component(hass, 'sensor') + + hold_temp = config[DOMAIN].get(HOLD_TEMP, False) + + # Fire thermostat discovery event + hass.bus.fire(EVENT_PLATFORM_DISCOVERED, { + ATTR_SERVICE: DISCOVER_THERMOSTAT, + ATTR_DISCOVERED: {'hold_temp': hold_temp} + }) + + # Fire sensor discovery event + hass.bus.fire(EVENT_PLATFORM_DISCOVERED, { + ATTR_SERVICE: DISCOVER_SENSORS, + ATTR_DISCOVERED: {} + }) + # pylint: disable=too-few-public-methods class EcobeeData(object): @@ -88,6 +124,7 @@ class EcobeeData(object): def update(self): """ Get the latest data from pyecobee. """ self.ecobee.update() + _LOGGER.info("ecobee data updated successfully.") def setup(hass, config): @@ -114,18 +151,7 @@ def setup(hass, config): NETWORK = EcobeeData(hass.config.path(ECOBEE_CONFIG_FILE)) - setup_ecobee(hass, NETWORK.ecobee) - - # Ensure component is loaded - bootstrap.setup_component(hass, 'thermostat', config) - - # Fire discovery event - hass.bus.fire(EVENT_PLATFORM_DISCOVERED, { - ATTR_SERVICE: DISCOVER_THERMOSTAT, - ATTR_DISCOVERED: { - 'network': NETWORK, - } - }) + setup_ecobee(hass, NETWORK.ecobee, config) def stop_ecobee(event): """ Stop Ecobee. """ diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 32ee59a6fa9..0d214475358 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -9,7 +9,7 @@ https://home-assistant.io/components/sensor/ import logging from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.components import wink, zwave, isy994, verisure +from homeassistant.components import wink, zwave, isy994, verisure, ecobee DOMAIN = 'sensor' DEPENDENCIES = [] @@ -22,7 +22,8 @@ DISCOVERY_PLATFORMS = { wink.DISCOVER_SENSORS: 'wink', zwave.DISCOVER_SENSORS: 'zwave', isy994.DISCOVER_SENSORS: 'isy994', - verisure.DISCOVER_SENSORS: 'verisure' + verisure.DISCOVER_SENSORS: 'verisure', + ecobee.DISCOVER_SENSORS: 'ecobee' } diff --git a/homeassistant/components/sensor/ecobee.py b/homeassistant/components/sensor/ecobee.py index a8d9e41acb1..b7663a70d6a 100644 --- a/homeassistant/components/sensor/ecobee.py +++ b/homeassistant/components/sensor/ecobee.py @@ -1,22 +1,39 @@ """ homeassistant.components.sensor.ecobee -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -This sensor component requires that the Ecobee Thermostat -component be setup first. This component shows remote -ecobee sensor data. +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Ecobee Thermostat Component + +This component adds support for Ecobee3 Wireless Thermostats. +You will need to setup developer access to your thermostat, +and create and API key on the ecobee website. + +The first time you run this component you will see a configuration +component card in Home Assistant. This card will contain a PIN code +that you will need to use to authorize access to your thermostat. You +can do this at https://www.ecobee.com/consumerportal/index.html +Click My Apps, Add application, Enter Pin and click Authorize. + +After authorizing the application click the button in the configuration +card. Now your thermostat and sensors should shown in home-assistant. + +You can use the optional hold_temp parameter to set whether or not holds +are set indefintely or until the next scheduled event. + +ecobee: + api_key: asdfasdfasdfasdfasdfaasdfasdfasdfasdf + hold_temp: True -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.ecobee/ """ from homeassistant.helpers.entity import Entity -import json +from homeassistant.components.ecobee import NETWORK +from homeassistant.const import TEMP_FAHRENHEIT import logging -import os -DEPENDENCIES = ['thermostat'] +DEPENDENCIES = ['ecobee'] SENSOR_TYPES = { - 'temperature': ['Temperature', '°F'], + 'temperature': ['Temperature', TEMP_FAHRENHEIT], 'humidity': ['Humidity', '%'], 'occupancy': ['Occupancy', ''] } @@ -26,24 +43,12 @@ _LOGGER = logging.getLogger(__name__) ECOBEE_CONFIG_FILE = 'ecobee.conf' -def config_from_file(filename): - ''' Small configuration file reading function ''' - if os.path.isfile(filename): - try: - with open(filename, 'r') as fdesc: - return json.loads(fdesc.read()) - except IOError as error: - _LOGGER.error("ecobee sensor couldn't read config file: " + error) - return False - else: - return {} - - def setup_platform(hass, config, add_devices, discovery_info=None): """ Sets up the sensors. """ - config = config_from_file(hass.config.path(ECOBEE_CONFIG_FILE)) + if discovery_info is None: + return dev = list() - for name, data in config['sensors'].items(): + for name, data in NETWORK.ecobee.sensors.items(): if 'temp' in data: dev.append(EcobeeSensor(name, 'temperature', hass)) if 'humidity' in data: @@ -80,14 +85,10 @@ class EcobeeSensor(Entity): return self._unit_of_measurement def update(self): - config = config_from_file(self.hass.config.path(ECOBEE_CONFIG_FILE)) - try: - data = config['sensors'][self.sensor_name] - if self.type == 'temperature': - self._state = data['temp'] - elif self.type == 'humidity': - self._state = data['humidity'] - elif self.type == 'occupancy': - self._state = data['occupancy'] - except KeyError: - print("Error updating ecobee sensors.") + data = NETWORK.ecobee.sensors[self.sensor_name] + if self.type == 'temperature': + self._state = data['temp'] + elif self.type == 'humidity': + self._state = data['humidity'] + elif self.type == 'occupancy': + self._state = data['occupancy'] diff --git a/homeassistant/components/thermostat/__init__.py b/homeassistant/components/thermostat/__init__.py index 480e3e4805e..f1a82a4c989 100644 --- a/homeassistant/components/thermostat/__init__.py +++ b/homeassistant/components/thermostat/__init__.py @@ -15,6 +15,7 @@ from homeassistant.config import load_yaml_config_file import homeassistant.util as util from homeassistant.helpers.entity import Entity from homeassistant.helpers.temperature import convert +from homeassistant.components import ecobee from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_ON, STATE_OFF, STATE_UNKNOWN, TEMP_CELCIUS) @@ -42,6 +43,10 @@ ATTR_OPERATION = "current_operation" _LOGGER = logging.getLogger(__name__) +DISCOVERY_PLATFORMS = { + ecobee.DISCOVER_THERMOSTAT: 'ecobee', +} + def set_away_mode(hass, away_mode, entity_id=None): """ Turn all or specified thermostat away mode on. """ @@ -67,7 +72,8 @@ def set_temperature(hass, temperature, entity_id=None): def setup(hass, config): """ Setup thermostats. """ - component = EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) + component = EntityComponent(_LOGGER, DOMAIN, hass, + SCAN_INTERVAL, DISCOVERY_PLATFORMS) component.setup(config) def thermostat_service(service): diff --git a/homeassistant/components/thermostat/ecobee.py b/homeassistant/components/thermostat/ecobee.py index 7f95aa7d1c6..51d21cb9991 100644 --- a/homeassistant/components/thermostat/ecobee.py +++ b/homeassistant/components/thermostat/ecobee.py @@ -15,17 +15,20 @@ can do this at https://www.ecobee.com/consumerportal/index.html Click My Apps, Add application, Enter Pin and click Authorize. After authorizing the application click the button in the configuration -card. Now your thermostat should shown in home-assistant. Once the -thermostat has been added you can add the ecobee sensor component -to your configuration.yaml. +card. Now your thermostat and sensors should shown in home-assistant. -thermostat: - platform: ecobee +You can use the optional hold_temp parameter to set whether or not holds +are set indefintely or until the next scheduled event. + +ecobee: api_key: asdfasdfasdfasdfasdfaasdfasdfasdfasdf + hold_temp: True + """ from homeassistant.components.thermostat import (ThermostatDevice, STATE_COOL, STATE_IDLE, STATE_HEAT) from homeassistant.const import (TEMP_FAHRENHEIT, STATE_ON, STATE_OFF) +from homeassistant.components.ecobee import NETWORK import logging DEPENDENCIES = ['ecobee'] @@ -38,30 +41,32 @@ _CONFIGURING = {} def setup_platform(hass, config, add_devices, discovery_info=None): """ Setup Platform """ - _LOGGER.error("ecobee !!!!") if discovery_info is None: return - data = discovery_info[0] - add_devices(Thermostat(data, index) + data = NETWORK + hold_temp = discovery_info['hold_temp'] + _LOGGER.info("Loading ecobee thermostat component with hold_temp set to " + + str(hold_temp)) + add_devices(Thermostat(data, index, hold_temp) for index in range(len(data.ecobee.thermostats))) class Thermostat(ThermostatDevice): """ Thermostat class for Ecobee """ - def __init__(self, data, thermostat_index): + def __init__(self, data, thermostat_index, hold_temp): self.data = data self.thermostat_index = thermostat_index self.thermostat = self.data.ecobee.get_thermostat( self.thermostat_index) self._name = self.thermostat['name'] self._away = 'away' in self.thermostat['program']['currentClimateRef'] + self.hold_temp = hold_temp def update(self): self.data.update() self.thermostat = self.data.ecobee.get_thermostat( self.thermostat_index) - _LOGGER.info("ecobee data updated successfully.") @property def name(self): @@ -157,7 +162,10 @@ class Thermostat(ThermostatDevice): def turn_away_mode_on(self): """ Turns away on. """ self._away = True - self.data.ecobee.set_climate_hold("away") + if self.hold_temp: + self.data.ecobee.set_climate_hold("away", "indefinite") + else: + self.data.ecobee.set_climate_hold("away") def turn_away_mode_off(self): """ Turns away off. """ @@ -169,7 +177,10 @@ class Thermostat(ThermostatDevice): temperature = int(temperature) low_temp = temperature - 1 high_temp = temperature + 1 - self.data.ecobee.set_hold_temp(low_temp, high_temp) + if self.hold_temp: + self.data.ecobee.set_hold_temp(low_temp, high_temp, "indefinite") + else: + self.data.ecobee.set_hold_temp(low_temp, high_temp) def set_hvac_mode(self, mode): """ Set HVAC mode (auto, auxHeatOnly, cool, heat, off) """ From 27bc4c582bc2fa7ce3b7467f2823525879d5ee12 Mon Sep 17 00:00:00 2001 From: "nkgilley@gmail.com" Date: Mon, 23 Nov 2015 11:40:54 -0500 Subject: [PATCH 10/12] update network data before sensor setup. --- homeassistant/components/sensor/ecobee.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sensor/ecobee.py b/homeassistant/components/sensor/ecobee.py index b7663a70d6a..1ef40bcca89 100644 --- a/homeassistant/components/sensor/ecobee.py +++ b/homeassistant/components/sensor/ecobee.py @@ -48,13 +48,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if discovery_info is None: return dev = list() + NETWORK.update() for name, data in NETWORK.ecobee.sensors.items(): if 'temp' in data: - dev.append(EcobeeSensor(name, 'temperature', hass)) + dev.append(EcobeeSensor(name, 'temperature')) if 'humidity' in data: - dev.append(EcobeeSensor(name, 'humidity', hass)) + dev.append(EcobeeSensor(name, 'humidity')) if 'occupancy' in data: - dev.append(EcobeeSensor(name, 'occupancy', hass)) + dev.append(EcobeeSensor(name, 'occupancy')) add_devices(dev) @@ -62,10 +63,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class EcobeeSensor(Entity): """ An ecobee sensor. """ - def __init__(self, sensor_name, sensor_type, hass): + def __init__(self, sensor_name, sensor_type): self._name = sensor_name + ' ' + SENSOR_TYPES[sensor_type][0] self.sensor_name = sensor_name - self.hass = hass self.type = sensor_type self._state = None self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] @@ -85,6 +85,7 @@ class EcobeeSensor(Entity): return self._unit_of_measurement def update(self): + NETWORK.update() data = NETWORK.ecobee.sensors[self.sensor_name] if self.type == 'temperature': self._state = data['temp'] From 80e829f53a1482757be9671b3ac864e2ced0f5ab Mon Sep 17 00:00:00 2001 From: "nkgilley@gmail.com" Date: Mon, 23 Nov 2015 11:52:02 -0500 Subject: [PATCH 11/12] was getting errors for NETWORK being None. looked like it was being loaded too early, so this will wait until it's ready --- homeassistant/components/sensor/ecobee.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/ecobee.py b/homeassistant/components/sensor/ecobee.py index 1ef40bcca89..524ac912405 100644 --- a/homeassistant/components/sensor/ecobee.py +++ b/homeassistant/components/sensor/ecobee.py @@ -48,7 +48,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if discovery_info is None: return dev = list() - NETWORK.update() + while NETWORK is None: + continue for name, data in NETWORK.ecobee.sensors.items(): if 'temp' in data: dev.append(EcobeeSensor(name, 'temperature')) From 067b5862c01731df43093085bbee2a514ff96667 Mon Sep 17 00:00:00 2001 From: "nkgilley@gmail.com" Date: Tue, 24 Nov 2015 09:29:33 -0500 Subject: [PATCH 12/12] bug fixes --- homeassistant/components/ecobee.py | 13 ------------- homeassistant/components/sensor/ecobee.py | 10 ++++------ homeassistant/components/thermostat/ecobee.py | 4 ++-- 3 files changed, 6 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/ecobee.py b/homeassistant/components/ecobee.py index 8b73a9969ef..03f17133501 100644 --- a/homeassistant/components/ecobee.py +++ b/homeassistant/components/ecobee.py @@ -30,7 +30,6 @@ from homeassistant.loader import get_component from homeassistant import bootstrap from homeassistant.util import Throttle from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_PLATFORM_DISCOVERED, ATTR_SERVICE, ATTR_DISCOVERED, CONF_API_KEY) from datetime import timedelta import logging @@ -153,16 +152,4 @@ def setup(hass, config): setup_ecobee(hass, NETWORK.ecobee, config) - def stop_ecobee(event): - """ Stop Ecobee. """ - - pass - - def start_ecobee(event): - """ Called when Home Assistant starts up. """ - - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_ecobee) - - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_ecobee) - return True diff --git a/homeassistant/components/sensor/ecobee.py b/homeassistant/components/sensor/ecobee.py index 524ac912405..a6499949015 100644 --- a/homeassistant/components/sensor/ecobee.py +++ b/homeassistant/components/sensor/ecobee.py @@ -26,7 +26,7 @@ ecobee: """ from homeassistant.helpers.entity import Entity -from homeassistant.components.ecobee import NETWORK +from homeassistant.components import ecobee from homeassistant.const import TEMP_FAHRENHEIT import logging @@ -48,9 +48,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if discovery_info is None: return dev = list() - while NETWORK is None: - continue - for name, data in NETWORK.ecobee.sensors.items(): + for name, data in ecobee.NETWORK.ecobee.sensors.items(): if 'temp' in data: dev.append(EcobeeSensor(name, 'temperature')) if 'humidity' in data: @@ -86,8 +84,8 @@ class EcobeeSensor(Entity): return self._unit_of_measurement def update(self): - NETWORK.update() - data = NETWORK.ecobee.sensors[self.sensor_name] + ecobee.NETWORK.update() + data = ecobee.NETWORK.ecobee.sensors[self.sensor_name] if self.type == 'temperature': self._state = data['temp'] elif self.type == 'humidity': diff --git a/homeassistant/components/thermostat/ecobee.py b/homeassistant/components/thermostat/ecobee.py index 51d21cb9991..78f4d555c9c 100644 --- a/homeassistant/components/thermostat/ecobee.py +++ b/homeassistant/components/thermostat/ecobee.py @@ -28,7 +28,7 @@ ecobee: from homeassistant.components.thermostat import (ThermostatDevice, STATE_COOL, STATE_IDLE, STATE_HEAT) from homeassistant.const import (TEMP_FAHRENHEIT, STATE_ON, STATE_OFF) -from homeassistant.components.ecobee import NETWORK +from homeassistant.components import ecobee import logging DEPENDENCIES = ['ecobee'] @@ -43,7 +43,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """ Setup Platform """ if discovery_info is None: return - data = NETWORK + data = ecobee.NETWORK hold_temp = discovery_info['hold_temp'] _LOGGER.info("Loading ecobee thermostat component with hold_temp set to " + str(hold_temp))