diff --git a/config/home-assistant.conf.example b/config/home-assistant.conf.example index f0dab565a17..8ca1ba2485d 100644 --- a/config/home-assistant.conf.example +++ b/config/home-assistant.conf.example @@ -33,6 +33,11 @@ platform=wemo # Optional: hard code the hosts (comma seperated) to avoid scanning the network # hosts=192.168.1.9,192.168.1.12 +[nest] +# Required: username and password that are used to login to the Nest thermostat. +username=myemail@mydomain.com +password=mypassword + [downloader] download_dir=downloads diff --git a/homeassistant/components/nest.py b/homeassistant/components/nest.py new file mode 100644 index 00000000000..c35cfba14bc --- /dev/null +++ b/homeassistant/components/nest.py @@ -0,0 +1,167 @@ +""" +homeassistant.components.nest +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Provides functionality to interact with Nest Thermostats. +""" +import logging +import homeassistant.util as util +from homeassistant.helpers import validate_config, ToggleDevice +from homeassistant.const import (ATTR_ENTITY_PICTURE, ATTR_UNIT_OF_MEASUREMENT, + ATTR_FRIENDLY_NAME, STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, ATTR_ENTITY_ID, + ATTR_NEW_TARGET_TEMPERATURE, SERVICE_SET_TARGET_TEMPERATURE) +from datetime import datetime, timedelta + +# The domain of your component. Should be equal to the name of your component +DOMAIN = "nest" +ENTITY_AWAY_NAME = "state away" +ENTITY_TEMP_INSIDE_ID = "nest_get.temperature_inside" +ENTITY_TEMP_TARGET_ID = "nest_get.temperature_target" +ENTITY_TEMP_TARGET_SET = "nest_set.temperature_target" + +ENTITY_AWAY_ID_FORMAT = DOMAIN + '.{}' + +# Configuration key for the entity id we are targeting +CONF_USERNAME = 'username' +CONF_PASSWORD = 'password' + +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = [] + +def is_on(hass, entity_id=None): + + return hass.states.is_state(entity_id, STATE_ON) + + +def turn_on(hass, entity_id=None): + """ Turns all or specified switch on. """ + data = {ATTR_ENTITY_ID: entity_id} if entity_id else None + + hass.services.call(DOMAIN, SERVICE_TURN_ON, data) + + +def turn_off(hass, entity_id=None): + """ Turns all or specified switch off. """ + data = {ATTR_ENTITY_ID: entity_id} if entity_id else None + + hass.services.call(DOMAIN, SERVICE_TURN_OFF, data) + +def set_temperature(hass, entity_id=None, new_temp=None): + """ Set new target temperature. """ + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + if new_temp: + data[ATTR_NEW_TARGET_TEMPERATURE] = new_temp + + hass.services.call(DOMAIN, SERVICE_SET_TARGET_TEMPERATURE, data) + +def setup(hass, config): + """ Setup NEST thermostat. """ + + # Validate that all required config options are given + if not validate_config(config, {DOMAIN: [CONF_USERNAME, CONF_PASSWORD]}, _LOGGER): + return False + + try: + import homeassistant.external.pynest.nest as pynest + except ImportError: + logging.getLogger(__name__).exception(( + "Failed to import pynest. ")) + return False + + username = config[DOMAIN][CONF_USERNAME] + password = config[DOMAIN][CONF_PASSWORD] + + thermostat = NestThermostat(pynest.Nest(username, password, None)) + thermostat.entity_id = ENTITY_AWAY_ID_FORMAT.format(util.slugify(ENTITY_AWAY_NAME)) + thermostat.nest.login() + + @util.Throttle(MIN_TIME_BETWEEN_SCANS) + def update_nest_state(now): + """ Update nest state. """ + + logging.getLogger(__name__).info("Update nest state") + + thermostat.nest.get_status() + thermostat.update_ha_state(hass) + + # Update state every 30 seconds + hass.track_time_change(update_nest_state, second=[0]) + update_nest_state(None) + + def handle_nest_service(service): + """ Handles calls to the nest services. """ + if service.service == SERVICE_TURN_ON: + thermostat.turn_on() + else: + thermostat.turn_off() + + thermostat.nest.get_status() + thermostat.update_ha_state(hass) + + hass.services.register(DOMAIN, SERVICE_TURN_OFF, handle_nest_service) + hass.services.register(DOMAIN, SERVICE_TURN_ON, handle_nest_service) + + def handle_nest_set_temperature(service): + if service.data[ATTR_NEW_TARGET_TEMPERATURE]: + new_temp = float(service.data[ATTR_NEW_TARGET_TEMPERATURE]) + thermostat.nest.set_temperature(new_temp) + thermostat.nest.get_status() + nest_temp(datetime.now()) + + hass.services.register(DOMAIN, SERVICE_SET_TARGET_TEMPERATURE, handle_nest_set_temperature) + + def nest_temp(time): + """ Method to get the current inside and target temperatures. """ + + #thermostat.nest.get_status() + current_temperature = thermostat.nest.get_curtemp() + target_temperature = thermostat.nest.get_tartemp() + + + hass.states.set(ENTITY_TEMP_INSIDE_ID, current_temperature, {ATTR_UNIT_OF_MEASUREMENT: thermostat.nest.get_units(), ATTR_ENTITY_PICTURE: + "https://cdn2.iconfinder.com/data/icons/windows-8-metro-ui-weather-report/512/Temperature.png"}) + + hass.states.set(ENTITY_TEMP_TARGET_ID, target_temperature, {ATTR_UNIT_OF_MEASUREMENT: thermostat.nest.get_units(), ATTR_ENTITY_PICTURE: + "http://d1hwvnnkb0v1bo.cloudfront.net/content/art/app/icons/target_icon.jpg"}) + + hass.track_time_change(nest_temp, second=[10]) + nest_temp(datetime.now()) + + # Tells the bootstrapper that the component was succesfully initialized + return True + +class NestThermostat(ToggleDevice): + + + def __init__(self, nest): + self.nest = nest + self.state_attr = {ATTR_FRIENDLY_NAME: ENTITY_AWAY_NAME, ATTR_ENTITY_PICTURE: + "http://support-assets.nest.com/images/tpzimages/app-energy-history-basic-away-icon.png"} + + def get_name(self): + """ Returns the name of the switch if any. """ + return ENTITY_AWAY_NAME + + def turn_on(self, **kwargs): + """ Turns away on. """ + self.nest.set_away("away") + + def turn_off(self): + """ Turns away off. """ + self.nest.set_away("here") + + def is_on(self): + """ True if away is on. """ + return self.nest.is_away() + + def get_state_attributes(self): + """ Returns optional state attributes. """ + return self.state_attr + + def set_temperature(self, temperature): + """ Set new target temperature """ + self.nest.set_temperature(temperature) + diff --git a/homeassistant/const.py b/homeassistant/const.py index 061dcbbd571..a7dae8a2886 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -53,6 +53,9 @@ ATTR_ENTITY_PICTURE = "entity_picture" # The unit of measurement if applicable ATTR_UNIT_OF_MEASUREMENT = "unit_of_measurement" +# New target temperature for thermostats +ATTR_NEW_TARGET_TEMPERATURE = "temp" + # #### SERVICES #### SERVICE_HOMEASSISTANT_STOP = "stop" @@ -68,6 +71,8 @@ SERVICE_MEDIA_PAUSE = "media_pause" SERVICE_MEDIA_NEXT_TRACK = "media_next_track" SERVICE_MEDIA_PREV_TRACK = "media_prev_track" +SERVICE_SET_TARGET_TEMPERATURE = "set_target_temperature" + # #### API / REMOTE #### SERVER_PORT = 8123 diff --git a/homeassistant/external/pynest/nest.py b/homeassistant/external/pynest/nest.py new file mode 100644 index 00000000000..081aed10a85 --- /dev/null +++ b/homeassistant/external/pynest/nest.py @@ -0,0 +1,365 @@ +#! /usr/bin/python + +# nest.py -- a python interface to the Nest Thermostat +# by Scott M Baker, smbaker@gmail.com, http://www.smbaker.com/ +# +# Adapted to Python 3 by Stefano Fiorini +# +# Usage: +# 'nest.py help' will tell you what to do and how to do it +# +# Licensing: +# This is distributed under the Creative Commons 3.0 Non-commercial, +# Attribution, Share-Alike license. You can use the code for noncommercial +# purposes. You may NOT sell it. If you do use it, then you must make an +# attribution to me (i.e. Include my name and thank me for the hours I spent +# on this) +# +# Acknowledgements: +# Chris Burris's Siri Nest Proxy was very helpful to learn the nest's +# authentication and some bits of the protocol. + +import time +import codecs +import urllib.request, urllib.parse, urllib.error +import urllib.request, urllib.error, urllib.parse +import sys +import re +import ssl +import http.client, socket +from optparse import OptionParser + +try: + import json +except ImportError: + try: + import simplejson as json + except ImportError: + print ("No json library available. I recommend installing either python-json") + print ("or simplejson. Python 2.6+ contains json library already.") + sys.exit(-1) + +#force connection to be TLSv1 +class HTTPSConnectionV1(http.client.HTTPSConnection): + def __init__(self, *args, **kwargs): + http.client.HTTPSConnection.__init__(self, *args, **kwargs) + + def connect(self): + sock = socket.create_connection((self.host, self.port), self.timeout) + self.sock = ssl.wrap_socket(sock, self.key_file, self.cert_file, ssl_version=ssl.PROTOCOL_TLSv1) + +class HTTPSHandlerV1(urllib.request.HTTPSHandler): + def https_open(self, req): + return self.do_open(HTTPSConnectionV1, req) +# install opener +urllib.request.install_opener(urllib.request.build_opener(HTTPSHandlerV1())) + +class Nest: + def __init__(self, username, password, serial=None, index=0, units="F", debug=False): + self.username = username + self.password = password + self.serial = serial + self.units = units + self.index = index + self.debug = debug + self.headers={"user-agent":"Nest/1.1.0.10 CFNetwork/548.0.4", + "X-nl-protocol-version": "1"} + def loads2(self, res): + binary_data = res.decode("utf-8") + return json.loads(binary_data) + + def loads(self, res): + reader = codecs.getreader("utf-8") + if hasattr(json, "loads"): + res = json.loads(reader(res)) + else: + res = json.read(reader(res)) + return res + + # context ['shared','structure','device'] + def handle_put(self, context, data): + assert context is not None, "Context must be set to ['shared','structure','device']" + assert data is not None, "Data is None" + + new_url = self.transport_url + "/v2/put/" + context + "." + + if (context == "shared" or context == "device"): + new_url += self.serial + elif (context == "structure"): + new_url += self.structure_id + else: + raise ValueError(context+ " is unsupported") + + binary_data = data.encode("utf-8") + req = urllib.request.Request(new_url, binary_data, self.headers) + + try: + urllib.request.urlopen(req).read() + except urllib.error.URLError: + print ("Put operation failed") + if (self.debug): + print (new_url) + print (data) + + def shared_put(self, data): + self.handle_put("shared", data) + + def device_put(self, data): + self.handle_put("device", data) + + def structure_put(self, data): + self.handle_put("structure", data) + + def login(self): + data = urllib.parse.urlencode({"username": self.username, "password": self.password}) + + binary_data = data.encode("utf-8") + req = urllib.request.Request("https://home.nest.com/user/login", + binary_data, self.headers) + + res = urllib.request.urlopen(req).read() + + res = self.loads2(res) + + self.transport_url = res["urls"]["transport_url"] + self.userid = res["userid"] + self.headers["Authorization"] = "Basic " + res["access_token"] + self.headers["X-nl-user-id"]= self.userid + + def get_status(self): + req = urllib.request.Request(self.transport_url + "/v2/mobile/user." + self.userid, + headers=self.headers) + + res = urllib.request.urlopen(req).read() + + res = self.loads2(res) + + self.structure_id = list(res["structure"].keys())[0] + + if (self.serial is None): + self.device_id = res["structure"][self.structure_id]["devices"][self.index] + self.serial = self.device_id.split(".")[1] + + self.status = res + + #print ("res.keys", res.keys()) + #print "res[structure][structure_id].keys", res["structure"][self.structure_id].keys() + #print "res[device].keys", res["device"].keys() + #print "res[device][serial].keys", res["device"][self.serial].keys() + #print "res[shared][serial].keys", res["shared"][self.serial].keys() + + def temp_in(self, temp): + if (self.units == "F"): + return (temp - 32.0) / 1.8 + else: + return temp + + def temp_out(self, temp): + if (self.units == "F"): + return temp*1.8 + 32.0 + else: + return temp + + def show_status(self): + shared = self.status["shared"][self.serial] + device = self.status["device"][self.serial] + structure = self.status["structure"][self.structure_id] + + # Delete the structure name so that we preserve the device name + del structure["name"] + allvars = shared + + allvars.update(structure) + allvars.update(device) + + for k, v in sorted(allvars.items()): + print((k + "."*(32-len(k)) + ":", self.format_value(k, v))) + + def format_value(self, key, value): + if 'temp' in key and isinstance(value, float) and self.units == 'F': + return '%s (%s F)' % (value, self.temp_out(value)) + + elif 'timestamp' in key or key == 'creation_time': + if value > 0xffffffff: + value /= 1000 + return time.ctime(value) + + elif key == 'mac_address' and len(value) == 12: + return ':'.join(value[i:i+2] for i in range(0, 12, 2)) + + else: + return str(value) + + def get_units(self): + return self.units + + def get_tartemp(self): + temp = self.status["shared"][self.serial]["target_temperature"] + temp = self.temp_out(temp) + temp = ("%0.0f" % temp) + + return temp + + def get_curtemp(self): + temp = self.status["shared"][self.serial]["current_temperature"] + temp = self.temp_out(temp) + temp = ("%0.1f" % temp) + + return temp + + def show_curtemp(self): + print(self.get_curtemp()) + + def is_away(self): + return self.status["structure"][self.structure_id]["away"] + + def set_temperature(self, temp): + temp = self.temp_in(temp) + data = '{"target_change_pending":true,"target_temperature":' + '%0.1f' % temp + '}' + self.shared_put(data) + + def set_fan(self, state): + data = '{"fan_mode":"' + str(state) + '"}' + self.device_put(data) + + def set_mode(self, state): + data = '{"target_temperature_type":"' + str(state) + '"}' + self.shared_put(data) + + def set_away(self, state): + time_since_epoch = time.time() + if (state == "away"): + data = '{"away_timestamp":' + str(time_since_epoch) + ',"away":true,"away_setter":0}' + else: + data = '{"away_timestamp":' + str(time_since_epoch) + ',"away":false,"away_setter":0}' + + self.structure_put(data) + + def set_auto_away(self, state): + if (state == "enable"): + data = '{"auto_away_enable":true}' + else: + data = '{"auto_away_enable":false}' + self.device_put(data) + +def create_parser(): + parser = OptionParser(usage="nest [options] command [command_options] [command_args]", + description="Commands: fan temp mode away auto-away", + version="unknown") + + parser.add_option("-u", "--user", dest="user", + help="username for nest.com", metavar="USER", default=None) + + parser.add_option("-p", "--password", dest="password", + help="password for nest.com", metavar="PASSWORD", default=None) + + parser.add_option("-c", "--celsius", dest="celsius", action="store_true", default=False, + help="use celsius instead of farenheit") + + parser.add_option("-s", "--serial", dest="serial", default=None, + help="optional, specify serial number of nest thermostat to talk to") + + parser.add_option("-d", "--debug", dest="debug", action="store_true", default=False, + help="Print debug information") + + parser.add_option("-i", "--index", dest="index", default=0, type="int", + help="optional, specify index number of nest to talk to") + + return parser + +def help(): + print ("syntax: nest [options] command [command_args]") + print ("options:") + print (" --user ... username on nest.com") + print (" --password ... password on nest.com") + print (" --celsius ... use celsius (the default is farenheit)") + print (" --serial ... optional, specify serial number of nest to use") + print (" --index ... optional, 0-based index of nest") + print (" (use --serial or --index, but not both)") + print () + print ("commands: temp, fan, away, mode, show, curtemp, curhumid") + print (" temp ... set target temperature") + print (" fan [auto|on] ... set fan state") + print (" away [away|here] ... set away state") + print (" auto-away [enable|disable]... enable or disable auto away") + print (" mode [heat|cool|range] ... set thermostat mode") + print (" show ... show everything") + print (" curtemp ... print current temperature") + print (" curhumid ... print current humidity") + print () + print ("examples:") + print (" nest.py --user joe@user.com --password swordfish temp 73") + print (" nest.py --user joe@user.com --password swordfish fan auto") + +def validate_temp(temp): + try: + new_temp = float(temp) + except ValueError: + return -1 + if new_temp < 50 or new_temp > 90: + return -1 + return new_temp + +def main(): + parser = create_parser() + (opts, args) = parser.parse_args() + + if (len(args)==0) or (args[0]=="help"): + help() + sys.exit(-1) + + if (not opts.user) or (not opts.password): + print ("how about specifying a --user and --password option next time?") + sys.exit(-1) + + if opts.celsius: + units = "C" + else: + units = "F" + + n = Nest(opts.user, opts.password, opts.serial, opts.index, units=units, debug=opts.debug) + n.login() + n.get_status() + + cmd = args[0] + + if (cmd == "temp"): + new_temp = -1 + if len(args)>1: + new_temp = validate_temp(args[1]) + if new_temp == -1: + print ("please specify a temperature between 50 and 90") + sys.exit(-1) + n.set_temperature(new_temp) + elif (cmd == "fan"): + if len(args)<2 or args[1] not in {"on", "auto"}: + print ("please specify a fan state of 'on' or 'auto'") + sys.exit(-1) + n.set_fan(args[1]) + elif (cmd == "mode"): + if len(args)<2 or args[1] not in {"cool", "heat", "range"}: + print ("please specify a thermostat mode of 'cool', 'heat' or 'range'") + sys.exit(-1) + n.set_mode(args[1]) + elif (cmd == "show"): + n.show_status() + elif (cmd == "curtemp"): + n.show_curtemp() + elif (cmd == "curhumid"): + print((n.status["device"][n.serial]["current_humidity"])) + elif (cmd == "away"): + if len(args)<2 or args[1] not in {"away", "here"}: + print ("please specify a state of 'away' or 'here'") + sys.exit(-1) + n.set_away(args[1]) + elif (cmd == "auto-away"): + if len(args)<2 or args[1] not in {"enable", "disable"}: + print ("please specify a state of 'enable' or 'disable'") + sys.exit(-1) + n.set_auto_away(args[1]) + else: + print(("misunderstood command:", cmd)) + print ("do 'nest.py help' for help") + +if __name__=="__main__": + main()