pax_global_header00006660000000000000000000000064150137021720014510gustar00rootroot0000000000000052 comment=7a8b7a5483c774312cf2bb817a6861de336086fc python-ecobee-api-0.3.0/000077500000000000000000000000001501370217200150205ustar00rootroot00000000000000python-ecobee-api-0.3.0/.gitignore000066400000000000000000000000501501370217200170030ustar00rootroot00000000000000.idea *.iml .DS_Store lib bin pyvenv.cfgpython-ecobee-api-0.3.0/LICENSE.txt000066400000000000000000000020501501370217200166400ustar00rootroot00000000000000MIT License Copyright (c) 2017 Nolan Gilley Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.python-ecobee-api-0.3.0/README.md000066400000000000000000000002311501370217200162730ustar00rootroot00000000000000# python-ecobee-api A Python library for controlling Ecobee3 wifi thermostats. ## Notes This is for use with [Home-Assistant](http://home-assistant.io) python-ecobee-api-0.3.0/pyecobee/000077500000000000000000000000001501370217200166135ustar00rootroot00000000000000python-ecobee-api-0.3.0/pyecobee/__init__.py000066400000000000000000001142741501370217200207350ustar00rootroot00000000000000""" Python Code for Communication with the Ecobee Thermostat """ import datetime from typing import Optional import requests from requests.exceptions import HTTPError, RequestException, Timeout try: import simplejson as json except ImportError: import json from .const import ( _LOGGER, ECOBEE_ACCESS_TOKEN, ECOBEE_API_KEY, ECOBEE_API_VERSION, ECOBEE_AUTH0_TOKEN, ECOBEE_AUTH_BASE_URL, ECOBEE_AUTHORIZATION_CODE, ECOBEE_BASE_URL, ECOBEE_CONFIG_FILENAME, ECOBEE_DEFAULT_TIMEOUT, ECOBEE_ENDPOINT_AUTH, ECOBEE_ENDPOINT_THERMOSTAT, ECOBEE_ENDPOINT_TOKEN, ECOBEE_OPTIONS_NOTIFICATIONS, ECOBEE_PASSWORD, ECOBEE_REFRESH_TOKEN, ECOBEE_USERNAME, ECOBEE_WEB_CLIENT_ID, ) from .errors import ExpiredTokenError, InvalidSensorError, InvalidTokenError from .util import config_from_file, convert_to_bool class Ecobee(object): """Class for communicating with the ecobee API.""" def __init__(self, config_filename: str = None, config: dict = None): self.thermostats = None self.config_filename = config_filename self.config = config self.api_key = None self.pin = None self.authorization_code = None self.access_token = None self.refresh_token = None self.username = None self.password = None self.auth0_token = None self.include_notifications = False if self.config_filename is None and self.config is None: _LOGGER.error("No ecobee credentials supplied, unable to continue") return if self.config: self._file_based_config = False self.api_key = self.config.get(ECOBEE_API_KEY) if ECOBEE_ACCESS_TOKEN in self.config: self.access_token = self.config[ECOBEE_ACCESS_TOKEN] if ECOBEE_AUTHORIZATION_CODE in self.config: self.authorization_code = self.config[ECOBEE_AUTHORIZATION_CODE] if ECOBEE_REFRESH_TOKEN in self.config: self.refresh_token = self.config[ECOBEE_REFRESH_TOKEN] if ECOBEE_USERNAME in self.config: self.username = self.config[ECOBEE_USERNAME] if ECOBEE_PASSWORD in self.config: self.password = self.config[ECOBEE_PASSWORD] if ECOBEE_AUTH0_TOKEN in self.config: self.auth0_token = self.config[ECOBEE_AUTH0_TOKEN] if ECOBEE_OPTIONS_NOTIFICATIONS in self.config: self.include_notifications = convert_to_bool(self.config[ECOBEE_OPTIONS_NOTIFICATIONS]) else: self._file_based_config = True def read_config_from_file(self) -> None: """Reads config info from passed-in config filename.""" if self._file_based_config: self.config = config_from_file(self.config_filename) self.api_key = self.config[ECOBEE_API_KEY] if ECOBEE_ACCESS_TOKEN in self.config: self.access_token = self.config[ECOBEE_ACCESS_TOKEN] if ECOBEE_AUTHORIZATION_CODE in self.config: self.authorization_code = self.config[ECOBEE_AUTHORIZATION_CODE] if ECOBEE_REFRESH_TOKEN in self.config: self.refresh_token = self.config[ECOBEE_REFRESH_TOKEN] if ECOBEE_USERNAME in self.config: self.username = self.config[ECOBEE_USERNAME] if ECOBEE_PASSWORD in self.config: self.password = self.config[ECOBEE_PASSWORD] if ECOBEE_AUTH0_TOKEN in self.config: self.auth0_token = self.config[ECOBEE_AUTH0_TOKEN] if ECOBEE_OPTIONS_NOTIFICATIONS in self.config: self.include_notifications = convert_to_bool(self.config[ECOBEE_OPTIONS_NOTIFICATIONS]) def _write_config(self) -> None: """Writes API tokens to a file or self.config if self.file_based_config is False.""" config = dict() config[ECOBEE_API_KEY] = self.api_key config[ECOBEE_ACCESS_TOKEN] = self.access_token config[ECOBEE_REFRESH_TOKEN] = self.refresh_token config[ECOBEE_USERNAME] = self.username config[ECOBEE_PASSWORD] = self.password config[ECOBEE_AUTH0_TOKEN] = self.auth0_token config[ECOBEE_AUTHORIZATION_CODE] = self.authorization_code config[ECOBEE_OPTIONS_NOTIFICATIONS] = str(self.include_notifications) if self._file_based_config: config_from_file(self.config_filename, config) else: self.config = config def request_pin(self) -> bool: """Requests a PIN from ecobee for authorization on ecobee.com.""" params = { "response_type": "ecobeePin", "client_id": self.api_key, "scope": "smartWrite", } log_msg_action = "request pin" response = self._request( "GET", ECOBEE_ENDPOINT_AUTH, log_msg_action, params=params, auth_request=True, ) try: self.authorization_code = response["code"] self.pin = response["ecobeePin"] _LOGGER.debug( f"Authorize your ecobee developer app with PIN code {self.pin}. " f"Goto https://www.ecobee/com/consumerportal/index.html, " f"Click My Apps, Add Application, Enter Pin and click Authorize. " f"After authorizing, call request_tokens method." ) return True except (KeyError, TypeError) as err: _LOGGER.debug(f"Error obtaining PIN code from ecobee: {err}") return False def request_tokens(self) -> bool: """Requests API tokens from ecobee.""" if self.auth0_token is not None: return self.request_tokens_web() params = { "grant_type": "ecobeePin", "code": self.authorization_code, "client_id": self.api_key, } log_msg_action = "request tokens" response = self._request( "POST", ECOBEE_ENDPOINT_TOKEN, log_msg_action, params=params, auth_request=True, ) try: self.access_token = response["access_token"] self.refresh_token = response["refresh_token"] self._write_config() self.pin = None _LOGGER.debug(f"Obtained tokens from ecobee: access {self.access_token}, " f"refresh {self.refresh_token}") return True except (KeyError, TypeError) as err: _LOGGER.debug(f"Error obtaining tokens from ecobee: {err}") return False def request_tokens_web(self) -> bool: assert self.auth0_token is not None, "auth0 token must be set before calling request_tokens_web" resp = requests.get(ECOBEE_AUTH_BASE_URL + "/" + ECOBEE_ENDPOINT_AUTH, cookies={"auth0": self.auth0_token}, params={ "client_id": ECOBEE_WEB_CLIENT_ID, "scope": "smartWrite", "response_type": "token", "response_mode": "form_post", "redirect_uri": "https://www.ecobee.com/home/authCallback", "audience": "https://prod.ecobee.com/api/v1", }, timeout=ECOBEE_DEFAULT_TIMEOUT) if resp.status_code != 200: _LOGGER.error(f"Failed to refresh access token: {resp.status_code} {resp.text}") return False if (auth0 := resp.cookies.get("auth0")) is None: _LOGGER.error("Failed to refresh access token: no auth0 cookie in response") self.auth0_token = auth0 # Parse the response HTML for the access token and expiration if (access_token := resp.text.split('name="access_token" value="')[1].split('"')[0]) is None: _LOGGER.error("Failed to refresh bearer token: no access token in response") return False self.access_token = access_token if (expires_in := resp.text.split('name="expires_in" value="')[1].split('"')[0]) is None: _LOGGER.error("Failed to refresh bearer token: no expiration in response") return False expires_at = datetime.datetime.now() + datetime.timedelta(seconds=int(expires_in)) _LOGGER.debug(f"Access token expires at {expires_at}") self._write_config() return True def refresh_tokens(self) -> bool: if self.username and self.password: self.request_auth0_token() if self.auth0_token is not None: return self.request_tokens_web() """Refreshes ecobee API tokens.""" params = { "grant_type": "refresh_token", "refresh_token": self.refresh_token, "client_id": self.api_key, } log_msg_action = "refresh tokens" response = self._request( "POST", ECOBEE_ENDPOINT_TOKEN, log_msg_action, params=params, auth_request=True, ) try: self.access_token = response["access_token"] self.refresh_token = response["refresh_token"] self._write_config() _LOGGER.debug(f"Refreshed tokens from ecobee: access {self.access_token}, " f"refresh {self.refresh_token}") return True except (KeyError, TypeError) as err: _LOGGER.debug(f"Error refreshing tokens from ecobee: {err}") return False def request_auth0_token(self) -> bool: """Get the auth0 token via username/password.""" session = requests.Session() url = f"{ECOBEE_AUTH_BASE_URL}/{ECOBEE_ENDPOINT_AUTH}" resp = session.get( url, params = { "response_type": "token", "response_mode": "form_post", "client_id": ECOBEE_WEB_CLIENT_ID, "redirect_uri": "https://www.ecobee.com/home/authCallback", "audience": "https://prod.ecobee.com/api/v1", "scope": "openid smartWrite piiWrite piiRead smartRead deleteGrants", } ) if resp.status_code != 200: _LOGGER.error(f"Failed to obtain auth0 token from {url}: {resp.status_code} {resp.text}") return False redirect_url = resp.url resp = session.post( redirect_url, data={ "username": self.username, "password": self.password, "action": "default" } ) if resp.status_code != 200: _LOGGER.error(f"Failed to obtain auth0 token from {redirect_url}: {resp.status_code} {resp.text}") return False if (auth0 := resp.cookies.get("auth0")) is None: _LOGGER.error(f"Failed to obtain auth0 token from {redirect_url}: no auth0 cookie in response") self.auth0_token = None return False _LOGGER.debug(f"Obtained auth0 token: {auth0}") self.auth0_token = auth0 return True def get_thermostats(self) -> bool: """Gets a json-list of thermostats from ecobee and caches in self.thermostats.""" param_string = { "selection": { "selectionType": "registered", "includeRuntime": "true", "includeSensors": "true", "includeProgram": "true", "includeEquipmentStatus": "true", "includeEvents": "true", "includeWeather": "true", "includeSettings": "true", "includeLocation": "true", } } if self.include_notifications: param_string["selection"]["includeNotificationSettings"] = self.include_notifications params = {"json": json.dumps(param_string)} log_msg_action = "get thermostats" response = self._request_with_refresh( "GET", ECOBEE_ENDPOINT_THERMOSTAT, log_msg_action, params=params ) try: self.thermostats = response["thermostatList"] return True except (KeyError, TypeError): return False def get_thermostat(self, index: int) -> str: """Returns a single thermostat based on list index of self.thermostats.""" return self.thermostats[index] def get_remote_sensors(self, index: int) -> str: """Returns remote sensors from a thermostat based on list index of self.thermostats.""" return self.thermostats[index]["remoteSensors"] def get_equipment_notifications(self, index: int) -> str: """Returns equipment notifications from a thermostat based on list index of self.thermostats.""" return self.thermostats[index]["notificationSettings"]["equipment"] def update(self) -> bool: """Gets new thermostat data from ecobee; wrapper for get_thermostats.""" return self.get_thermostats() def set_hvac_mode(self, index: int, hvac_mode: str) -> None: """Sets the HVAC mode (auto, auxHeatOnly, cool, heat, off).""" body = { "selection": { "selectionType": "thermostats", "selectionMatch": self.thermostats[index]["identifier"], }, "thermostat": {"settings": {"hvacMode": hvac_mode}}, } log_msg_action = "set HVAC mode" try: self._request_with_refresh( "POST", ECOBEE_ENDPOINT_THERMOSTAT, log_msg_action, body=body ) except (ExpiredTokenError, InvalidTokenError) as err: raise err def set_fan_min_on_time(self, index: int, fan_min_on_time: int) -> None: """Sets the minimum time, in minutes, to run the fan each hour (1 to 60).""" body = { "selection": { "selectionType": "thermostats", "selectionMatch": self.thermostats[index]["identifier"], }, "thermostat": {"settings": {"fanMinOnTime": fan_min_on_time}}, } log_msg_action = "set fan minimum on time" try: self._request_with_refresh( "POST", ECOBEE_ENDPOINT_THERMOSTAT, log_msg_action, body=body ) except (ExpiredTokenError, InvalidTokenError) as err: raise err def set_fan_mode( self, index: int, fan_mode: str, hold_type: str, **optional_arg, ) -> None: """ Sets the fan mode (auto, minontime, on). valid optional_arg holdHours - required if HoldType is holdHours coolHoldTemp heatHoldTemp """ body = { "selection": { "selectionType": "thermostats", "selectionMatch": self.thermostats[index]["identifier"], }, "functions": [ { "type": "setHold", "params": { "holdType": hold_type, "fan": fan_mode, }, } ], } # Set the optional args if "holdHours" in optional_arg: # Required if and only if hold_type == holdHours if hold_type == "holdHours": body["functions"][0]["params"]["holdHours"] = int(optional_arg["holdHours"]) if "coolHoldTemp" in optional_arg: body["functions"][0]["params"]["coolHoldTemp"] = int(optional_arg["coolHoldTemp"]) * 10 if "heatHoldTemp" in optional_arg: body["functions"][0]["params"]["heatHoldTemp"] = int(optional_arg["heatHoldTemp"]) * 10 log_msg_action = "set fan mode" try: self._request_with_refresh( "POST", ECOBEE_ENDPOINT_THERMOSTAT, log_msg_action, body=body ) except (ExpiredTokenError, InvalidTokenError) as err: raise err def set_hold_temp( self, index: int, cool_temp: float, heat_temp: float, hold_type: str = "nextTransition", hold_hours: str = "2", ) -> None: """Sets a hold temperature.""" if hold_type == "holdHours": body = { "selection": { "selectionType": "thermostats", "selectionMatch": self.thermostats[index]["identifier"], }, "functions": [ { "type": "setHold", "params": { "holdType": hold_type, "coolHoldTemp": int(cool_temp * 10), "heatHoldTemp": int(heat_temp * 10), "holdHours": hold_hours, }, } ], } else: body = { "selection": { "selectionType": "thermostats", "selectionMatch": self.thermostats[index]["identifier"], }, "functions": [ { "type": "setHold", "params": { "holdType": hold_type, "coolHoldTemp": int(cool_temp * 10), "heatHoldTemp": int(heat_temp * 10), }, } ], } log_msg_action = "set hold temp" try: self._request_with_refresh( "POST", ECOBEE_ENDPOINT_THERMOSTAT, log_msg_action, body=body ) except (ExpiredTokenError, InvalidTokenError) as err: raise err def set_climate_hold( self, index: int, climate: str, hold_type: str = "nextTransition", hold_hours: int = None ) -> None: """Sets a climate hold (away, home, sleep).""" body = { "selection": { "selectionType": "thermostats", "selectionMatch": self.thermostats[index]["identifier"], }, "functions": [ { "type": "setHold", "params": {"holdType": hold_type, "holdClimateRef": climate, "holdHours": hold_hours}, } ], } if hold_type != "holdHours": del body["functions"][0]["params"]["holdHours"] log_msg_action = "set climate hold" try: self._request_with_refresh( "POST", ECOBEE_ENDPOINT_THERMOSTAT, log_msg_action, body=body ) except (ExpiredTokenError, InvalidTokenError) as err: raise err def create_vacation( self, index: int, vacation_name: str, cool_temp: float, heat_temp: float, start_date: str = None, start_time: str = None, end_date: str = None, end_time: str = None, fan_mode: str = "auto", fan_min_on_time: str = "0", ) -> None: """Creates a vacation.""" body = { "selection": { "selectionType": "thermostats", "selectionMatch": self.thermostats[index]["identifier"], }, "functions": [ { "type": "createVacation", "params": { "name": vacation_name, "coolHoldTemp": int(cool_temp * 10), "heatHoldTemp": int(heat_temp * 10), "startDate": start_date, "startTime": start_time, "endDate": end_date, "endTime": end_time, "fan": fan_mode, "fanMinOnTime": fan_min_on_time, }, } ], } log_msg_action = "create a vacation" try: self._request_with_refresh( "POST", ECOBEE_ENDPOINT_THERMOSTAT, log_msg_action, body=body ) except (ExpiredTokenError, InvalidTokenError) as err: raise err def delete_vacation(self, index: int, vacation: str) -> None: """Deletes a vacation.""" body = { "selection": { "selectionType": "thermostats", "selectionMatch": self.thermostats[index]["identifier"], }, "functions": [{"type": "deleteVacation", "params": {"name": vacation}}], } log_msg_action = "delete a vacation" try: self._request_with_refresh( "POST", ECOBEE_ENDPOINT_THERMOSTAT, log_msg_action, body=body ) except (ExpiredTokenError, InvalidTokenError) as err: raise err def resume_program(self, index: int, resume_all: bool = False) -> None: """Resumes the currently scheduled program.""" body = { "selection": { "selectionType": "thermostats", "selectionMatch": self.thermostats[index]["identifier"], }, "functions": [ {"type": "resumeProgram", "params": {"resumeAll": resume_all}} ], } log_msg_action = "resume program" try: self._request_with_refresh( "POST", ECOBEE_ENDPOINT_THERMOSTAT, log_msg_action, body=body ) except (ExpiredTokenError, InvalidTokenError) as err: raise err def send_message(self, index: int, message: str = None) -> None: """Sends the first 500 characters of a message to the thermostat.""" if message is None: message = "Hello from pyecobee!" body = { "selection": { "selectionType": "thermostats", "selectionMatch": self.thermostats[index]["identifier"], }, "functions": [{"type": "sendMessage", "params": {"text": message[0:500]}}], } log_msg_action = "send message" try: self._request_with_refresh( "POST", ECOBEE_ENDPOINT_THERMOSTAT, log_msg_action, body=body ) except (ExpiredTokenError, InvalidTokenError) as err: raise err def set_dehumidifier_mode(self, index: int, dehumidifier_mode: str) -> None: """Sets the dehumidifier mode (on, off).""" body = { "selection": { "selectionType": "thermostats", "selectionMatch": self.thermostats[index]["identifier"], }, "thermostat": {"settings": {"dehumidifierMode": dehumidifier_mode}}, } log_msg_action = "set dehumidifier mode" try: self._request_with_refresh( "POST", ECOBEE_ENDPOINT_THERMOSTAT, log_msg_action, body=body ) except (ExpiredTokenError, InvalidTokenError) as err: raise err def set_dehumidifier_level(self, index: int, dehumidifier_level: int) -> None: """Sets the dehumidification set point in percentage.""" body = { "selection": { "selectionType": "thermostats", "selectionMatch": self.thermostats[index]["identifier"], }, "thermostat": {"settings": {"dehumidifierLevel": dehumidifier_level}} } log_msg_action = "set dehumidifier level" try: self._request_with_refresh( "POST", ECOBEE_ENDPOINT_THERMOSTAT, log_msg_action, body=body ) except (ExpiredTokenError, InvalidTokenError) as err: raise err def set_humidifier_mode(self, index: int, humidifier_mode: str) -> None: """Sets the humidifier mode (auto, off, manual).""" body = { "selection": { "selectionType": "thermostats", "selectionMatch": self.thermostats[index]["identifier"], }, "thermostat": {"settings": {"humidifierMode": humidifier_mode}}, } log_msg_action = "set humidifier mode" try: self._request_with_refresh( "POST", ECOBEE_ENDPOINT_THERMOSTAT, log_msg_action, body=body ) except (ExpiredTokenError, InvalidTokenError) as err: raise err def set_humidity(self, index: int, humidity: str) -> None: """Sets target humidity level.""" body = { "selection": { "selectionType": "thermostats", "selectionMatch": self.thermostats[index]["identifier"], }, "thermostat": {"settings": {"humidity": str(humidity)}}, } log_msg_action = "set humidity level" try: self._request_with_refresh( "POST", ECOBEE_ENDPOINT_THERMOSTAT, log_msg_action, body=body ) except (ExpiredTokenError, InvalidTokenError) as err: raise err def set_mic_mode(self, index: int, mic_enabled: bool) -> None: """Enables/Disables Alexa microphone (only for ecobee4).""" body = { "selection": { "selectionType": "thermostats", "selectionMatch": self.thermostats[index]["identifier"], }, "thermostat": {"audio": {"microphoneEnabled": mic_enabled}}, } log_msg_action = "set mic mode" try: self._request_with_refresh( "POST", ECOBEE_ENDPOINT_THERMOSTAT, log_msg_action, body=body ) except (ExpiredTokenError, InvalidTokenError) as err: raise err def set_occupancy_modes( self, index: int, auto_away: bool = None, follow_me: bool = None ) -> None: """Enables/Disables Smart Home/Away and Follow Me modes.""" body = { "selection": { "selectionType": "thermostats", "selectionMatch": self.thermostats[index]["identifier"], }, "thermostat": { "settings": {"autoAway": auto_away, "followMeComfort": follow_me} }, } log_msg_action = "set occupancy modes" try: self._request_with_refresh( "POST", ECOBEE_ENDPOINT_THERMOSTAT, log_msg_action, body=body ) except (ExpiredTokenError, InvalidTokenError) as err: raise err def set_dst_mode(self, index: int, enable_dst: bool) -> None: """Enables/Disables daylight savings time.""" body = { "selection": { "selectionType": "thermostats", "selectionMatch": self.thermostats[index]["identifier"], }, "thermostat": {"location": {"isDaylightSaving": enable_dst}}, } log_msg_action = "set dst mode" try: self._request_with_refresh( "POST", ECOBEE_ENDPOINT_THERMOSTAT, log_msg_action, body=body ) except (ExpiredTokenError, InvalidTokenError) as err: raise err def set_vent_mode(self, index: int, vent_mode: str) -> None: """Sets the ventilator mode. Values: auto, minontime, on, off.""" body = { "selection": { "selectionType": "thermostats", "selectionMatch": self.thermostats[index]["identifier"], }, "thermostat": {"settings": {"vent": vent_mode}}, } log_msg_action = "set vent mode" try: self._request_with_refresh( "POST", ECOBEE_ENDPOINT_THERMOSTAT, log_msg_action, body=body ) except (ExpiredTokenError, InvalidTokenError) as err: raise err def set_ventilator_min_on_time(self, index: int, ventilator_min_on_time: int) -> None: """Sets the minimum time in minutes the ventilator is configured to run.""" body = { "selection": { "selectionType": "thermostats", "selectionMatch": self.thermostats[index]["identifier"], }, "thermostat": {"settings": {"ventilatorMinOnTime": ventilator_min_on_time}}, } log_msg_action = "set ventilator minimum on time" try: self._request_with_refresh( "POST", ECOBEE_ENDPOINT_THERMOSTAT, log_msg_action, body=body ) except (ExpiredTokenError, InvalidTokenError) as err: raise err def set_ventilator_min_on_time_home(self, index: int, ventilator_min_on_time_home: int) -> None: """Sets the number of minutes to run ventilator per hour when home.""" body = { "selection": { "selectionType": "thermostats", "selectionMatch": self.thermostats[index]["identifier"], }, "thermostat": {"settings": {"ventilatorMinOnTimeHome": ventilator_min_on_time_home}}, } log_msg_action = "set ventilator minimum on time when homw" try: self._request_with_refresh( "POST", ECOBEE_ENDPOINT_THERMOSTAT, log_msg_action, body=body ) except (ExpiredTokenError, InvalidTokenError) as err: raise err def set_ventilator_min_on_time_away(self, index: int, ventilator_min_on_time_away: int) -> None: """Sets the number of minutes to run ventilator per hour when away.""" body = { "selection": { "selectionType": "thermostats", "selectionMatch": self.thermostats[index]["identifier"], }, "thermostat": {"settings": {"ventilatorMinOnTimeAway": ventilator_min_on_time_away}}, } log_msg_action = "set ventilator minimum on time when away" try: self._request_with_refresh( "POST", ECOBEE_ENDPOINT_THERMOSTAT, log_msg_action, body=body ) except (ExpiredTokenError, InvalidTokenError) as err: raise err def set_ventilator_timer(self, index: int, ventilator_on: bool) -> None: """Sets whether the ventilator timer is on or off.""" body = { "selection": { "selectionType": "thermostats", "selectionMatch": self.thermostats[index]["identifier"], }, "thermostat": {"settings": {"isVentilatorTimerOn": ventilator_on}}, } log_msg_action = "set ventilator timer" try: self._request_with_refresh( "POST", ECOBEE_ENDPOINT_THERMOSTAT, log_msg_action, body=body ) except (ExpiredTokenError, InvalidTokenError) as err: raise err def set_aux_cutover_threshold(self, index: int, threshold: int) -> None: """Set the threshold for outdoor temp below which alt heat will be used.""" body = { "selection": { "selectionType": "thermostats", "selectionMatch": self.thermostats[index]["identifier"], }, "thermostat": {"settings": {"compressorProtectionMinTemp": int(threshold * 10)}}, } log_msg_action = "set outdoor temp threshold for aux" try: self._request_with_refresh( "POST", ECOBEE_ENDPOINT_THERMOSTAT, log_msg_action, body=body ) except (ExpiredTokenError, InvalidTokenError) as err: raise err def update_climate_sensors(self, index: int, climate_name: str, sensor_names: Optional[list]=None, sensor_ids: Optional[list]=None) -> None: """Get current climate program. Must provide either `sensor_names` or `ids`.""" # Ensure only either `sensor_names` or `ids` was provided. if sensor_names is None and sensor_ids is None: raise ValueError("Need to provide either `sensor_names` or `ids`.") if sensor_names and sensor_ids: raise ValueError("Either `sensor_names` or `ids` should be provided, not both.") programs: dict = self.thermostats[index]["program"] # Remove currentClimateRef key. programs.pop("currentClimateRef", None) for i, climate in enumerate(programs["climates"]): if climate["name"] == climate_name: climate_index = i sensors = self.get_remote_sensors(index) sensor_list = [] if sensor_ids: """Update climate sensors with sensor_ids list.""" for id in sensor_ids: for sensor in sensors: if sensor["id"] == id: sensor_list.append( {"id": "{}:1".format(id), "name": sensor["name"]}) if sensor_names: """Update climate sensors with sensor_names list.""" for name in sensor_names: """Find the sensor id from the name.""" for sensor in sensors: if sensor["name"] == name: sensor_list.append( {"id": "{}:1".format(sensor["id"]), "name": name}) if len(sensor_list) == 0: raise InvalidSensorError("no sensor matching provided ids or names on thermostat") try: programs["climates"][climate_index]["sensors"] = sensor_list except UnboundLocalError: """This would occur if the climate_index was not assigned because the climate name does not exist in the program climates.""" return """Updates Climate""" body = { "selection": { "selectionType": "thermostats", "selectionMatch": self.thermostats[index]["identifier"], }, "thermostat": { "program": programs } } log_msg_action = "upate climate sensors" try: self._request_with_refresh( "POST", ECOBEE_ENDPOINT_THERMOSTAT, log_msg_action, body=body ) except (ExpiredTokenError, InvalidTokenError) as err: raise err def _request_with_refresh( self, method: str, endpoint: str, log_msg_action: str, params: dict = None, body: dict = None, auth_request: bool = False, ) -> Optional[str]: """ Wrapper around _request, to refresh tokens if needed. If an ExpiredTokenError is seen call refresh_tokens and try one more time. Otherwise, send the results up """ response = None refreshed = False for _ in range(0, 2): try: response = self._request( method, endpoint, log_msg_action, params, body, auth_request ) except ExpiredTokenError: if not refreshed: # Refresh tokens and try again self.refresh_tokens() refreshed = True continue else: # Send the exception up the stack otherwise raise except InvalidTokenError: raise # Success, fall out of the loop break return response def _request( self, method: str, endpoint: str, log_msg_action: str, params: dict = None, body: dict = None, auth_request: bool = False, ) -> Optional[str]: """Makes a request to the ecobee API.""" url = f"{ECOBEE_BASE_URL}/{endpoint}" headers = dict() if not auth_request: url = f"{ECOBEE_BASE_URL}/{ECOBEE_API_VERSION}/{endpoint}" headers = { "Content-Type": "application/json;charset=UTF-8", "Authorization": f"Bearer {self.access_token}", } _LOGGER.debug( f"Making request to {endpoint} endpoint to {log_msg_action}: " f"url: {url}, headers: {headers}, params: {params}, body: {body}" ) try: response = requests.request( method, url, headers=headers, params=params, json=body, timeout=ECOBEE_DEFAULT_TIMEOUT ) try: log_msg = response.json() except: log_msg = response.text _LOGGER.debug( f"Request response: {response.status_code}: {log_msg}" ) response.raise_for_status() return response.json() except HTTPError: json_payload = {} try: json_payload = response.json() except json.decoder.JSONDecodeError: _LOGGER.debug("Invalid JSON payload received") if auth_request: if ( response.status_code == 400 and json_payload.get("error") == "invalid_grant" ): raise InvalidTokenError( "ecobee tokens invalid; re-authentication required" ) else: _LOGGER.error( f"Error requesting authorization from ecobee: " f"{response.status_code}: {json_payload}" ) elif response.status_code == 500: code = json_payload.get("status", {}).get("code") if code in [1, 16]: raise InvalidTokenError( "ecobee tokens invalid; re-authentication required" ) elif code == 14: raise ExpiredTokenError( "ecobee access token expired; token refresh required" ) else: _LOGGER.error( f"Error from ecobee while attempting to {log_msg_action}: " f"{code}: {json_payload.get('status', {}).get('message', 'Unknown error')}" ) else: _LOGGER.error( f"Error from ecobee while attempting to {log_msg_action}: " f"{response.status_code}: {json_payload}" ) except Timeout: _LOGGER.error( f"Connection to ecobee timed out while attempting to {log_msg_action}. " f"Possible connectivity outage." ) except json.decoder.JSONDecodeError: _LOGGER.error( f"Error decoding response from ecobee while attempting to {log_msg_action}. " ) except RequestException as err: _LOGGER.error( f"Error connecting to ecobee while attempting to {log_msg_action}. " f"Possible connectivity outage.\n" f"{err}" ) return None python-ecobee-api-0.3.0/pyecobee/const.py000066400000000000000000000025651501370217200203230ustar00rootroot00000000000000"""Constants used in this library.""" import logging _LOGGER = logging.getLogger("pyecobee") ECOBEE_USERNAME = "USERNAME" ECOBEE_PASSWORD = "PASSWORD" ECOBEE_ACCESS_TOKEN = "ACCESS_TOKEN" ECOBEE_API_KEY = "API_KEY" ECOBEE_AUTHORIZATION_CODE = "AUTHORIZATION_CODE" ECOBEE_REFRESH_TOKEN = "REFRESH_TOKEN" ECOBEE_AUTH0_TOKEN = "AUTH0_TOKEN" ECOBEE_CONFIG_FILENAME = "ecobee.conf" ECOBEE_DEFAULT_TIMEOUT = 30 ECOBEE_OPTIONS_NOTIFICATIONS = "INCLUDE_NOTIFICATIONS" ECOBEE_STATE_UNKNOWN = -5002 ECOBEE_STATE_CALIBRATING = -5003 ECOBEE_VALUE_UNKNOWN = "unknown" ECOBEE_BASE_URL = "https://api.ecobee.com" ECOBEE_AUTH_BASE_URL = "https://auth.ecobee.com" ECOBEE_ENDPOINT_AUTH = "authorize" ECOBEE_ENDPOINT_TOKEN = "token" ECOBEE_ENDPOINT_THERMOSTAT = "thermostat" ECOBEE_API_VERSION = "1" ECOBEE_WEB_CLIENT_ID = "183eORFPlXyz9BbDZwqexHPBQoVjgadh" ECOBEE_MODEL_TO_NAME = { "idtSmart": "ecobee Smart Thermostat", "idtEms": "ecobee Smart EMS Thermostat", "siSmart": "ecobee Si Smart Thermostat", "siEms": "ecobee Si EMS Thermostat", "athenaSmart": "ecobee3 Smart Thermostat", "athenaEms": "ecobee3 EMS Thermostat", "corSmart": "Carrier/Bryant Cor Thermostat", "nikeSmart": "ecobee3 lite Smart Thermostat", "nikeEms": "ecobee3 lite EMS Thermostat", "apolloSmart": "ecobee4 Smart Thermostat", "vulcanSmart": "ecobee Smart Thermostat with Voice Control", } python-ecobee-api-0.3.0/pyecobee/errors.py000066400000000000000000000007351501370217200205060ustar00rootroot00000000000000"""Errors used in this library.""" class EcobeeError(Exception): """Base class for all ecobee exceptions.""" pass class ExpiredTokenError(EcobeeError): """Raised when ecobee API returns a code indicating expired credentials.""" pass class InvalidTokenError(EcobeeError): """Raised when ecobee API returns a code indicating invalid credentials.""" class InvalidSensorError(EcobeeError): """Raised when remote sensor not present on thermostat."""python-ecobee-api-0.3.0/pyecobee/util.py000066400000000000000000000020271501370217200201430ustar00rootroot00000000000000"""Utility functions for the python-ecobee-api library.""" import os from typing import Optional try: import simplejson as json except ImportError: import json from .const import _LOGGER def config_from_file(filename: str, config: dict = None) -> Optional[str]: """Reads/writes json from/to a filename.""" if config: # We're writing configuration try: with open(filename, "w") as fdesc: fdesc.write(json.dumps(config)) return True except IOError as error: _LOGGER.exception(error) return False 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: _LOGGER.exception(error) return False else: return {} def convert_to_bool(input) -> bool: return str(input).lower() in ["true", "1", "t", "y", "yes"]python-ecobee-api-0.3.0/requirements.txt000066400000000000000000000000171501370217200203020ustar00rootroot00000000000000requests>=2,<3 python-ecobee-api-0.3.0/setup.cfg000066400000000000000000000000501501370217200166340ustar00rootroot00000000000000[metadata] description-file = README.md python-ecobee-api-0.3.0/setup.py000077500000000000000000000032211501370217200165330ustar00rootroot00000000000000#!/usr/bin/env python import os import sys try: from setuptools import setup except ImportError: from distutils.core import setup if sys.argv[-1] == 'publish': os.system('python setup.py sdist upload') sys.exit() license = """ MIT License Copyright (c) 2017 Nolan Gilley Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ setup(name='python-ecobee-api', version='0.3.0', description='Python API for talking to Ecobee thermostats', url='https://github.com/nkgilley/python-ecobee-api', author='Nolan Gilley', author_email='nkgilley@gmail.com', license='MIT', install_requires=['requests>=2.25'], packages=['pyecobee'], zip_safe=True)