├── .gitignore ├── API.py ├── TStat.py └── TStatGcal.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | 3 | -------------------------------------------------------------------------------- /API.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | #Copyright (c) 2011, Paul Jennings 4 | #All rights reserved. 5 | 6 | #Redistribution and use in source and binary forms, with or without 7 | #modification, are permitted provided that the following conditions are met: 8 | # 9 | # * Redistributions of source code must retain the above copyright notice, 10 | # this list of conditions and the following disclaimer. 11 | # * Redistributions in binary form must reproduce the above copyright 12 | # notice, this list of conditions and the following disclaimer in the 13 | # documentation and/or other materials provided with the distribution. 14 | # * The names of its contributors may not be used to endorse or promote 15 | # products derived from this software without specific prior written 16 | # permission. 17 | 18 | #THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | #AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | #IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 21 | #ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 22 | #LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 23 | #CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 24 | #SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 25 | #INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 26 | #CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 27 | #ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF 28 | #THE POSSIBILITY OF SUCH DAMAGE. 29 | 30 | # API.py 31 | # API definitions for Radio Thermostat wifi-enabled thermostats. 32 | 33 | # This file allows multiple APIs to be defined for different versions of the 34 | # thermostat hardware/software. Currently there is only one API defined, for 35 | # the 3M-50/CT30. Each API should be defined as a subclass of API. You must 36 | # define models and versions to be a list of versions and models (as retrieved 37 | # from the thermostat) that your API subclass supports. entries must be 38 | # defined as a dict such that the keys are named pieces of data and the values 39 | # are instances of APIEntry. (See APIv1 for an example.) 40 | # 41 | # Example APIEntry: 42 | # 'fmode': APIEntry( 43 | # [('/tstat/fmode', 'fmode'), ('/tstat', 'fmode')], 44 | # [('/tstat/fmode', 'fmode')], 45 | # {'0': 'Auto', '1': '??', '2':'On'} #TODO: Check these values 46 | # ) 47 | # APIEntry has three members: 48 | # getters: A list of tuples where the first entry is a URL on the thermostat 49 | # and the second entry is the JSON key used to retrieve the piece 50 | # of data. In the above example, fmode can be retrieved either 51 | # from /tstat/fmode using key fmode, or from /tstat using key 52 | # fmode. Multiple values are allowed here to help support 53 | # changes in hardware API. If fmode is removed from the /tstat 54 | # output, the system will automatically fall back to /tstat/fmode. 55 | # setters: A list of tuples in the same format as getters, used for setting 56 | # values. 57 | # valueMap: A dict of possible outputs and the human-readable value they 58 | # should be mapped to. In the above example, fmode=0 is mapped to 59 | # 'Auto', while fmode=2 is mapped to 'On'. 60 | # 61 | # Extending an existing API: 62 | # Assume that in a new hardware/software revision, power usage data in KWH is 63 | # available at /tstat/kwh. A new API could be defined as follows: 64 | # 65 | # class APIv2(APIv1): 66 | # def __init__(self): 67 | # self.entries['fmode'].getters = [('/tstat/kwh', 'kwh')] 68 | # 69 | # You would most likely also want to add access functions to TStat.py as 70 | # well. 71 | 72 | class APIEntry: 73 | def __init__(self, getters, setters, valueMap=None, usesJson=True): 74 | self.getters = getters 75 | self.setters = setters 76 | self.valueMap = valueMap 77 | self.usesJson = usesJson 78 | 79 | class API: 80 | models = [] 81 | successStrings = [] 82 | entries = None 83 | 84 | def __getitem__(self, item): 85 | return self.entries[item] 86 | 87 | def has_key(self, key): 88 | return self.entries.has_key(key) 89 | 90 | entries = { 91 | 'model': APIEntry( 92 | [('/tstat/model', 'model')], 93 | [] 94 | ) 95 | } 96 | 97 | class API_CT50v109(API): 98 | models = ['CT50 V1.09'] 99 | successStrings = [ 100 | "Tstat Command Processed", 101 | "Cloud updates have been suspended till reboot", 102 | "Cloud updates activated" 103 | ] 104 | entries = { 105 | 'fmode': APIEntry( 106 | [('/tstat/fmode', 'fmode'), ('/tstat', 'fmode')], 107 | [('/tstat/fmode', 'fmode')], 108 | {0: 'Auto', 1: '??', 2:'On'} #TODO: Check these values 109 | ), 110 | 'tmode': APIEntry( 111 | [('/tstat/tmode', 'tmode'), ('/tstat', 'tmode')], 112 | [('/tstat/tmode', 'tmode')], 113 | {0: 'Off', 1: 'On'} #TODO: Check these values 114 | ), 115 | 'temp': APIEntry( 116 | [('/tstat', 'temp'), ('/temp', 'temp')], 117 | [] 118 | ), 119 | 'override': APIEntry( 120 | [('/tstat', 'override'), ('/tstat/info', 'override'), ('/tstat/override', 'override')], 121 | [], 122 | {0: False, 1: True} 123 | ), 124 | 'hold': APIEntry( 125 | [('/tstat', 'hold'), ('/tstat/info', 'hold'), ('/tstat/hold', 'hold')], 126 | [('/tstat/hold', 'hold')], 127 | {0: False, 1: True} 128 | ), 129 | 't_heat': APIEntry( 130 | [('/tstat/info', 't_heat'), ('/tstat/ttemp', 't_heat')], 131 | [('/tstat/ttemp', 't_heat')] 132 | ), 133 | 't_cool': APIEntry( 134 | [('/tstat/info', 't_cool'), ('/tstat/ttemp', 't_cool')], 135 | [('/tstat/ttemp', 't_cool')] 136 | ), 137 | 'tstate': APIEntry( 138 | [('/tstat', 'tstate')], 139 | [], 140 | {0: 'Off', 1: 'On'} 141 | ), 142 | 'fstate': APIEntry( 143 | [('/tstat', 'fstate')], 144 | [], 145 | {0: 'Off', 1: 'On'} 146 | ), 147 | 'day': APIEntry( 148 | [('/tstat', 'time/day')], 149 | [], 150 | {0: 'Monday', 1: 'Tuesday', 2: 'Wednesday', 3: 'Thursday', 4: 'Friday', 5: 'Saturday', 6: 'Sunday'} 151 | ), 152 | 'hour': APIEntry( 153 | [('/tstat', 'time/hour')], 154 | [] 155 | ), 156 | 'minute': APIEntry( 157 | #[('/tstat', 'time/minute'), ('/tstat/time/minute', 'day')], 158 | [('/tstat', 'time/minute')], 159 | [] 160 | ), 161 | 'today_heat_runtime': APIEntry( 162 | [('/tstat/datalog', 'today/heat_runtime')], 163 | [] 164 | ), 165 | 'today_cool_runtime': APIEntry( 166 | [('/tstat/datalog', 'today/cool_runtime')], 167 | [] 168 | ), 169 | 'yesterday_heat_runtime': APIEntry( 170 | [('/tstat/datalog', 'yesterday/heat_runtime')], 171 | [] 172 | ), 173 | 'yesterday_cool_runtime': APIEntry( 174 | [('/tstat/datalog', 'yesterday/cool_runtime')], 175 | [] 176 | ), 177 | 'errstatus': APIEntry( 178 | [('/tstat/errstatus', 'errstatus')], 179 | [], 180 | {0: 'OK'} 181 | ), 182 | 'model': APIEntry( 183 | [('/tstat/model', 'model')], 184 | [] 185 | ), 186 | 'power': APIEntry( 187 | [('/tstat/power', 'power')], 188 | [('/tstat/power', 'power')] 189 | ), 190 | 'cloud_mode': APIEntry( 191 | [], 192 | [('/cloud/mode', 'command')], 193 | usesJson=False 194 | ) 195 | #'eventlog': #TODO 196 | } 197 | 198 | class API_CT30v192(API_CT50v109): 199 | models = ['CT30 V1.92'] 200 | 201 | APIs = [API_CT50v109, API_CT30v192] 202 | 203 | def getAPI(model): 204 | for api in APIs: 205 | if model in api.models: 206 | return api() 207 | -------------------------------------------------------------------------------- /TStat.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | #Copyright (c) 2011, Paul Jennings 4 | #All rights reserved. 5 | 6 | #Contributors: 7 | # Billy J. West 8 | 9 | #Redistribution and use in source and binary forms, with or without 10 | #modification, are permitted provided that the following conditions are met: 11 | # 12 | # * Redistributions of source code must retain the above copyright notice, 13 | # this list of conditions and the following disclaimer. 14 | # * Redistributions in binary form must reproduce the above copyright 15 | # notice, this list of conditions and the following disclaimer in the 16 | # documentation and/or other materials provided with the distribution. 17 | # * The names of its contributors may not be used to endorse or promote 18 | # products derived from this software without specific prior written 19 | # permission. 20 | 21 | #THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 22 | #AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 23 | #IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 24 | #ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 25 | #LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 26 | #CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 27 | #SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 28 | #INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 29 | #CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 30 | #ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF 31 | #THE POSSIBILITY OF SUCH DAMAGE. 32 | 33 | # TStat.py 34 | # Python interface for Radio Thermostat wifi-enabled thermostats. 35 | 36 | # Usage: 37 | # t = TStat('1.2.3.4') # Where 1.2.3.4 is your thermostat's IP address 38 | # t.getCurrentTemp() # Returns current temperature as a float 39 | # t.setHeatPoint(68.0) # Sets target temperature for heating to 68.0 40 | # ... 41 | # 42 | # 43 | # A simple cache based on retrieved URL and time of retrieval is included. 44 | # If you call TStat.getFanMode(), /tstat will be retrieved and the value 45 | # for fmode will be return. If you then call t.getTState(), a cached 46 | # value will already exist and tstate will be returned from that. 47 | # 48 | # You can change the time for cache expiration by calling 49 | # t.setCacheExpiry(timeInSeconds). 50 | 51 | import datetime 52 | import httplib 53 | import urllib 54 | import logging 55 | import random 56 | import socket 57 | import time 58 | 59 | # For Python < 2.6, this json module: 60 | # http://pypi.python.org/pypi/python-json 61 | # will work. 62 | try: 63 | from json import read as loads 64 | from json import write as dumps 65 | except ImportError: 66 | from json import loads 67 | from json import dumps 68 | 69 | from API import * 70 | 71 | class CacheEntry: 72 | def __init__(self, location, data): 73 | self.location = location 74 | self.data = data 75 | self.time = datetime.datetime.now() 76 | 77 | def age(self): 78 | return datetime.datetime.now()-self.time 79 | 80 | class TStat: 81 | def __init__(self, address, cacheExpiry=5, api=None, logger=None, logLevel=None): 82 | self.address = address 83 | self.setCacheExpiry(cacheExpiry) 84 | self.cache = {} 85 | if logger is None: 86 | if logLevel is None: 87 | logLevel = logging.WARNING 88 | logging.basicConfig(level=logLevel) 89 | self.logger = logging.getLogger('TStat') 90 | else: 91 | self.logger = logger 92 | if api is None: 93 | self.api = API() 94 | self.api = getAPI(self.getModel()) 95 | time.sleep(2) 96 | else: 97 | self.api = api 98 | 99 | def setCacheExpiry(self, newExpiry): 100 | self.cacheExpiry = datetime.timedelta(seconds=newExpiry) 101 | 102 | def _getConn(self): 103 | """Used internally to get a connection to the tstat.""" 104 | return httplib.HTTPConnection(self.address) 105 | 106 | def _post(self, key, value): 107 | """Used internally to modify tstat settings (e.g. cloud mode).""" 108 | 109 | l = self.logger 110 | 111 | # Check for valid request 112 | if not self.api.has_key(key): 113 | l.error("%s does not exist in API" % key) 114 | return False 115 | 116 | # Retrieve the mapping from api key to thermostat URL 117 | entry = self.api[key] 118 | l.debug("Got API entry: %s" % entry) 119 | 120 | try: 121 | if len(entry.setters) < 1: 122 | raise TypeError 123 | except TypeError: 124 | l.error("%s cannot be set (maybe readonly?)" % key) 125 | return False 126 | 127 | # Check for valid values 128 | if entry.valueMap is not None: 129 | inverse = dict((v,k) for k, v in entry.valueMap.iteritems()) 130 | if not inverse.has_key(value) and not entry.valueMap.has_key(value): 131 | l.warning("Value '%s' may not be a valid value for '%s'" % (value, key)) 132 | elif inverse.has_key(value): 133 | value = inverse[value] 134 | 135 | for setter in entry.setters: 136 | location = setter[0] 137 | jsonKey = setter[1] 138 | if entry.usesJson: 139 | params = dumps({jsonKey: value}) 140 | else: 141 | params = urllib.urlencode({jsonKey: value}) 142 | l.debug("Will send params: %s" % params) 143 | 144 | headers = {"Content-type": "application/x-www-form-urlencoded", "Accept": "text/plain"} 145 | response = None 146 | count = 0 147 | while response is None and count < 5: 148 | try: 149 | conn = self._getConn() 150 | conn.request("POST", location, params, headers) 151 | response = conn.getresponse() 152 | except socket.error: 153 | response = None 154 | if response is None: 155 | time.sleep(count*random.randint(0, 3)) 156 | count = count + 1 157 | if response.status != 200: 158 | l.error("Error %s while trying to set '%s' with '%s'" % (response.status, location, params)) 159 | continue 160 | data = response.read() 161 | response.close() 162 | 163 | success = False 164 | for s in self.api.successStrings: 165 | if data.startswith(s): 166 | success = True 167 | break 168 | 169 | if not success: 170 | l.error("Error trying to set '%s' with '%s': %s" % (location, params, data)) 171 | l.debug("Response: %s" % data) 172 | return True 173 | 174 | def _get(self, key, raw=False): 175 | """Used internally to retrieve data from the tstat and process it with JSON if necessary.""" 176 | 177 | l = self.logger 178 | l.debug("Requested: %s" % key) 179 | 180 | # Check for valid request 181 | if not self.api.has_key(key): 182 | #TODO: Error processing 183 | l.debug("%s does not exist in API" % key) 184 | return 185 | 186 | # Retrieve the mapping from api key to thermostat URL 187 | entry = self.api[key] 188 | l.debug("Got API entry: %s" % entry) 189 | 190 | # First check cache 191 | newest = None 192 | for getter in entry.getters: 193 | location = getter[0] 194 | jsonKey = getter[1] 195 | if self.cache.has_key(location): 196 | cacheEntry = self.cache[location] 197 | l.debug("Found cache entry: %s" % cacheEntry) 198 | if cacheEntry.age() < self.cacheExpiry: 199 | l.debug("Entry is valid") 200 | try: 201 | if cacheEntry.age() < self.cache[newest[0]].age(): 202 | l.debug("Entry is now newest entry") 203 | newest = getter 204 | except TypeError: 205 | l.debug("Entry is first valid entry") 206 | newest = getter 207 | else: 208 | l.debug("Entry is invalid (expired)") 209 | 210 | response = None 211 | if newest is not None: 212 | # At least one valid entry was found in the cache 213 | l.debug("Using cached entry") 214 | response = self.cache[newest[0]].data 215 | else: 216 | for getter in entry.getters: 217 | # Either data was not cached or cache was expired 218 | response = None 219 | count = 0 220 | while response is None and count < 5: 221 | try: 222 | conn = self._getConn() 223 | conn.request("GET", getter[0]) 224 | response = conn.getresponse() 225 | except socket.error: 226 | response = None 227 | if response is None: 228 | time.sleep(count*random.randint(0, 10)) 229 | count = count + 1 230 | if response.status != 200: 231 | l.warning("Request for '%s' failed (error %s)" % (getter[0], response.status)) 232 | response = None 233 | continue 234 | data = response.read() 235 | response.close() 236 | l.debug("Got response: %s" % data) 237 | try: 238 | response = loads(data) 239 | if 'error_msg' not in response: 240 | break 241 | except: 242 | l.warning("Some problem with response: %s" % data) 243 | response = None 244 | continue 245 | 246 | if response is None: 247 | l.error("Unable to retrieve '%s' from any of %s" % (key, entry.getters)) 248 | return 249 | self.cache[getter[0]] = CacheEntry(getter[0], response) 250 | 251 | # Allow mappings to subdictionaries in json data 252 | # e.g. 'today/heat_runtime' from '/tstat/datalog' 253 | for key in getter[1].split("/"): 254 | try: 255 | response = response[key] 256 | except: 257 | pass 258 | 259 | if raw or entry.valueMap is None: 260 | # User requested raw data or there is no value mapping 261 | return response 262 | 263 | # User requested processing 264 | l.debug("Mapping response") 265 | try: 266 | l.debug("%s --> %s" % (response, entry.valueMap[response])) 267 | return entry.valueMap[response] 268 | except: 269 | l.debug("Didn't find '%s' in %s" % (response, entry.valueMap)) 270 | return response 271 | 272 | def getCurrentTemp(self, raw=False): 273 | """Returns current temperature measurement.""" 274 | return self._get('temp', raw) 275 | 276 | def getTstatMode(self, raw=False): 277 | """Returns current thermostat mode.""" 278 | return self._get('tmode', raw) 279 | 280 | def setTstatMode(self, value): 281 | """Sets thermostat mode.""" 282 | return self._post('tmode', value) 283 | 284 | def getFanMode(self, raw=False): 285 | """Returns current fan mode.""" 286 | return self._get('fmode', raw) 287 | 288 | def setFanMode(self, value): 289 | """Sets fan mode.""" 290 | return self._post('fmode', value) 291 | 292 | def getOverride(self, raw=False): 293 | """Returns current override setting""" 294 | return self._get('override', raw) 295 | 296 | def getPower(self, raw=False): 297 | """Returns power?""" 298 | return self._get('power', raw) 299 | 300 | def setPower(self, value): 301 | """Sets power?""" 302 | return self._post('power', value) 303 | 304 | def getHoldState(self, raw=False): 305 | """Returns current hold state.""" 306 | return self._get('hold', raw) 307 | 308 | def setHoldState(self, value): 309 | """Sets hold state.""" 310 | return self._post('hold', value) 311 | 312 | def getHeatPoint(self, raw=False): 313 | """Returns current set point for heat.""" 314 | return self._get('t_heat', raw) 315 | 316 | def setHeatPoint(self, value): 317 | """Sets point for heat.""" 318 | return self._post('t_heat', value) 319 | 320 | def getCoolPoint(self, raw=False): 321 | """Returns current set point for cooling.""" 322 | return self._get('t_cool', raw) 323 | 324 | def setCoolPoint(self, value): 325 | """Sets point for cooling.""" 326 | return self._post('t_cool', value) 327 | 328 | def getSetPoints(self, raw=False): 329 | """Returns both heating and cooling set points.""" 330 | return (self.getHeatPoint(), self.getCoolPoint()) 331 | 332 | def getModel(self, raw=False): 333 | """Returns the model of the thermostat.""" 334 | return self._get('model', raw) 335 | 336 | def getTState(self, raw=False): 337 | """Returns current thermostat state.""" 338 | return self._get('tstate', raw) 339 | 340 | def getFanState(self, raw=False): 341 | """Returns current fan state.""" 342 | return self._get('fstate', raw) 343 | 344 | def getTime(self, raw=False): 345 | """Returns current time.""" 346 | return {'day': self._get('day'), 'hour': self._get('hour'), 'minute': self._get('minute')} 347 | 348 | def getHeatUsageToday(self, raw=False): 349 | """Returns heat usage for today.""" 350 | return self._get('today_heat_runtime') 351 | 352 | def getHeatUsageYesterday(self, raw=False): 353 | """Returns heat usage for yesterday.""" 354 | return self._get('yesterday_heat_runtime') 355 | 356 | def getCoolUsageToday(self, raw=False): 357 | """Returns cool usage for today.""" 358 | return self._get('today_cool_runtime') 359 | 360 | def getCoolUsageYesterday(self, raw=False): 361 | """Returns cool usage for yesterday.""" 362 | return self._get('yesterday_cool_runtime') 363 | 364 | def isOK(self): 365 | """Returns true if thermostat reports that it is OK.""" 366 | return self._get('errstatus') == 'OK' 367 | 368 | def getErrStatus(self): 369 | """Returns current error code or 0 if everything is OK.""" 370 | return self._get('errstatus') 371 | 372 | def getEventLog(self): 373 | """Returns events?""" 374 | pass 375 | 376 | def setCloudMode(self, value): 377 | """Sets cloud mode to state.""" 378 | return self._post("cloud_mode", value) 379 | 380 | def discover(): 381 | import struct 382 | import select 383 | import re 384 | 385 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 386 | sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2) 387 | sock.sendto("TYPE: WM-DISCOVER\r\nVERSION: 1.0\r\n\r\nservices: com.marvell.wm.system*\r\n\r\n", ("239.255.255.250", 1900)) 388 | 389 | mreq = struct.pack("=4sl", socket.inet_aton("239.255.255.250"), socket.INADDR_ANY) 390 | sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) 391 | 392 | sock.setblocking(0) 393 | 394 | ready = select.select([sock], [], [], 30) 395 | data = None 396 | if ready[0]: 397 | data = sock.recv(4096).replace("\r\n", "\n") 398 | 399 | m = re.search("http://([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)/(.*?)$", data, re.MULTILINE) 400 | 401 | try: 402 | return m.group(1) 403 | except: 404 | raise ValueError, "Didn't find any thermostats on the local network" 405 | 406 | def main(): 407 | import sys 408 | addr = discover() 409 | 410 | t = TStat(addr, api=API_CT50v109()) 411 | for cmd in sys.argv[1:]: 412 | result = eval("t.%s(raw=True)" % cmd) 413 | #print "%s: %s" % (cmd, result) 414 | print result 415 | 416 | if __name__ == '__main__': 417 | main() 418 | -------------------------------------------------------------------------------- /TStatGcal.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | #Copyright (c) 2011, Paul Jennings 4 | #All rights reserved. 5 | 6 | #Redistribution and use in source and binary forms, with or without 7 | #modification, are permitted provided that the following conditions are met: 8 | # 9 | # * Redistributions of source code must retain the above copyright notice, 10 | # this list of conditions and the following disclaimer. 11 | # * Redistributions in binary form must reproduce the above copyright 12 | # notice, this list of conditions and the following disclaimer in the 13 | # documentation and/or other materials provided with the distribution. 14 | # * The names of its contributors may not be used to endorse or promote 15 | # products derived from this software without specific prior written 16 | # permission. 17 | 18 | #THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | #AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | #IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 21 | #ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 22 | #LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 23 | #CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 24 | #SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 25 | #INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 26 | #CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 27 | #ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF 28 | #THE POSSIBILITY OF SUCH DAMAGE. 29 | 30 | VERSION = 1.0 31 | 32 | # TStatGcal.py 33 | # Script to pull commands from Google Calendar and update thermostat. 34 | # 35 | # Requirements: 36 | # * gdata (http://code.google.com/p/gdata-python-client/) 37 | # * ElementTree (http://effbot.org/zone/element-index.htm) 38 | # * Python-TStat (same place you got this script) 39 | # 40 | # Usage: 41 | # 1. Create a Google/GMail account (or Google Apps for domains). 42 | # 2. Go to http://calendar.google.com 43 | # 3. Create a calendar (called "Thermostat" for example). 44 | # 4. Add events with titles of the form: 45 | # "Heat 70" -- sets heat to 70 degrees 46 | # "Cool 70" -- sets cool to 70 degrees 47 | # "Fan On" -- forces fan on 48 | # "Mode Off" -- forces system off 49 | # 5. Run the following commands (assuming Unix/Linux system): 50 | # echo "youraccount@gmail.com" >> ~/.google 51 | # echo "yourpassword" >> ~/.google 52 | # chmod 400 ~/.google 53 | # (where "youraccount@gmail.com" is the account that you created in 54 | # step 1 and "yourpassword" is your password) 55 | # 6. Add the following to your crontab to run every 5 minutes or so: 56 | # TStatGcal.py 57 | # Where is the IP address of your thermostat 58 | # and is the name of the calendar you created in 59 | # step 3. 60 | # 61 | # Notes: 62 | # In order to limit the chance that this script sets your 63 | # thermostat to dangerous settings (e.g. too low or off during 64 | # the winter, there are some override variables below: 65 | # HEAT_MIN, HEAT_MAX: Minimum/maximum setting for heat 66 | # COOL_MIN, COOL_MAX: Minimum/maximum setting for cool 67 | # COMMANDS: What parts of the thermostat the script is 68 | # allowed to control 69 | # 70 | # Set the HEAT/COOL variables to appropriate values for your 71 | # situation. By default, this script will not set the 72 | # thermostat mode (on/off/auto). You probably want to leave 73 | # it on auto. This is to prevent a hacker (or a typo) from 74 | # turning your furnace off during the winter. 75 | # 76 | # By default, this script does not disable cloud updates. 77 | # That way, if this script does not run for some reason (e.g. 78 | # if your computer crashes), you can still have a reasonable 79 | # backup program running. When the cloud updates your thermostat, 80 | # there may be a short period where the setting does not match 81 | # what is on your calendar. If this behavior is undesirable, you 82 | # can disable cloud updates. 83 | # 84 | # At the start time of your event, the script will set the 85 | # the thermostat to the requested setting. The duration of the 86 | # events on your calendar is ignored. For example, a simple 87 | # program might look like this: 88 | # 6:30 -- Heat 70 89 | # 8:00 -- Heat 60 90 | # 16:00 -- Heat 70 91 | # 22:00 -- Heat 60 92 | # In order to create this program in your calendar, you would need 93 | # four events. If you create a "Heat 70" event that lasts from 94 | # 6:30-22:00 and an overlapping "Heat 60" event that lasts from 95 | # 8:00-16:00, you will effectively miss the "Heat 70" command at 96 | # 16:00. Only the start time of the event is used. 97 | 98 | # Minimum and maximum values for heat and cool 99 | # The script will never set values outside of this range 100 | HEAT_MIN = 55 101 | HEAT_MAX = 80 102 | COOL_MIN = 70 103 | COOL_MAX = 100 104 | 105 | # Valid commands 106 | # Remove commands that you don't want the script to execute here 107 | # mode in particular can be dangerous, because someone could create 108 | # a 'mode off' command and turn your heat off in the winter. 109 | #COMMANDS = ['Heat', 'Cool', 'Mode', 'Fan'] 110 | COMMANDS = ['Heat', 'Cool', 'Fan'] 111 | 112 | PERIODS = ['Wake', 'Leave', 'Home', 'Sleep'] 113 | 114 | try: 115 | from xml.etree import ElementTree # for Python 2.5 users 116 | except ImportError: 117 | from elementtree import ElementTree 118 | import gdata.calendar.service 119 | import gdata.service 120 | import atom.service 121 | import gdata.calendar 122 | 123 | import atom 124 | import datetime 125 | import getopt 126 | import os 127 | import sys 128 | import string 129 | import time 130 | 131 | import TStat 132 | 133 | def getCalendarService(username, password): 134 | # Log in to Google 135 | calendar_service = gdata.calendar.service.CalendarService() 136 | calendar_service.email = username 137 | calendar_service.password = password 138 | calendar_service.source = "TStatGCal-%s" % VERSION 139 | calendar_service.ProgrammaticLogin() 140 | return calendar_service 141 | 142 | def main(tstatAddr, commandMap=None, username=None, password=None, calName="Thermostat"): 143 | # Connect to thermostat 144 | tstat = TStat.TStat(tstatAddr) 145 | 146 | # Command map is used to translate things like "Wake" into "Heat 70" 147 | if commandMap is None: 148 | commandMap = {} 149 | 150 | calendar_service = getCalendarService(username, password) 151 | 152 | # Create date range for event search 153 | today = datetime.datetime.today() 154 | gmt = time.gmtime() 155 | gmtDiff = datetime.datetime(gmt[0], gmt[1], gmt[2], gmt[3], gmt[4]) - today 156 | tomorrow = datetime.datetime.today()+datetime.timedelta(days=8) 157 | 158 | query = gdata.calendar.service.CalendarEventQuery() 159 | query.start_min = "%04i-%02i-%02i" % (today.year, today.month, today.day) 160 | query.start_max = "%04i-%02i-%02i" % (tomorrow.year, tomorrow.month, tomorrow.day) 161 | 162 | print "start_min:", query.start_min 163 | print "start_max:", query.start_max 164 | 165 | # Look for a calendar called calName 166 | feed = calendar_service.GetOwnCalendarsFeed() 167 | for i, a_calendar in enumerate(feed.entry): 168 | if a_calendar.title.text == calName: 169 | query.feed = a_calendar.content.src 170 | 171 | if query.feed is None: 172 | print "No calendar with name '%s' found" % calName 173 | return 174 | 175 | # Search for the event that has passed but is closest to the current time 176 | # There is probably a better way to do this... 177 | closest = None 178 | closestDT = None 179 | closestWhen = None 180 | closestEvent = None 181 | closestCommand = None 182 | closestValue = None 183 | periods = {} 184 | feed = calendar_service.CalendarQuery(query) 185 | for i, an_event in enumerate(feed.entry): 186 | #print '\t%s. %s' % (i, an_event.title.text,) 187 | 188 | # Try to map named time period into actual command 189 | text = an_event.title.text.strip() 190 | 191 | if not text in PERIODS: 192 | if commandMap.has_key(text): 193 | text = commandMap[text] 194 | 195 | print "Translated %s into %s" % (an_event.title.text.strip(), text) 196 | 197 | # Skip events that are not valid commands 198 | try: 199 | (command, value) = text.splitlines()[0].split() 200 | except: 201 | command = text 202 | if command not in COMMANDS: 203 | print "Warning: '%s' is not a valid command" % text 204 | continue 205 | try: 206 | float(value) 207 | except: 208 | if value not in ['Off', 'On', 'Auto']: 209 | print "Warning: '%s' is not a valid command" % an_event.title.text 210 | continue 211 | for a_when in an_event.when: 212 | d = a_when.start_time.split("T")[0] 213 | t = a_when.start_time.split("T")[1].split(".")[0] 214 | (year, month, day) = [int(p) for p in d.split("-")] 215 | (hour, min, sec) = [int(p) for p in t.split(":")] 216 | dt = datetime.datetime(year, month, day, hour, min, sec)-gmtDiff 217 | #print "DT:", dt 218 | d = dt-datetime.datetime.today() 219 | #print "d.days:", d.days 220 | 221 | if text in PERIODS: 222 | if not periods.has_key(dt.day): 223 | periods[dt.day] = {} 224 | periods[dt.day][text] = dt 225 | else: 226 | # Skip events that are in the future 227 | if d.days >= 0: 228 | continue 229 | 230 | if closest is None: 231 | closest = d 232 | closestDT = dt 233 | closestWhen = a_when 234 | closestEvent = an_event 235 | closestCommand = command 236 | closestValue = value 237 | else: 238 | if d.days < closest.days: 239 | continue 240 | if d.seconds > closest.seconds: 241 | closest = d 242 | closestDT = dt 243 | closestWhen = a_when 244 | closestEvent = an_event 245 | closestCommand = command 246 | closestValue = value 247 | 248 | print "Found periods:", periods 249 | 250 | # Handle programmed periods 251 | periodCommands = {} 252 | for day in range(0,7): 253 | if not periodCommands.has_key(day): 254 | periodCommands[day] = [] 255 | for p in PERIODS: 256 | 257 | if periods.has_key(p): 258 | periodCommands.append(int(periods[p].hour*60+periods[p].minute)) 259 | periodCommands.append(int(commandMap[p].split()[-1])) 260 | else: 261 | periodCommands.append(periodCommands[-2]) 262 | periodCommands.append(periodCommands[-2]) 263 | 264 | print "Commands:", periodCommands 265 | 266 | if closestEvent is None: 267 | print "No events found" 268 | return 269 | 270 | text = closestEvent.title.text 271 | print "Closest event: %s at %s" % (text, closestDT) 272 | #(command, value) = text.splitlines()[0].split() 273 | command, value = (closestCommand, closestValue) 274 | if command == 'Heat': 275 | value = int(value) 276 | if value >= HEAT_MIN and value <= HEAT_MAX: 277 | print "Setting heat to %s" % int(value) 278 | #tstat.setHeatPoint(value) 279 | else: 280 | print "Value out of acceptable heat range:", value 281 | elif command == 'Cool': 282 | value = int(value) 283 | if value >= COOL_MIN and value <= COOL_MAX: 284 | print "Setting cool to %s" % value 285 | tstat.setCoolPoint(int(value)) 286 | else: 287 | print "Value out of acceptable cool range:", value 288 | elif command == 'Fan': 289 | print "Setting fan to %s" % value 290 | tstat.setFanMode(value) 291 | elif command == 'Mode': 292 | print "Setting mode to %s" % value 293 | tstat.setTstatMode(value) 294 | 295 | if __name__ == '__main__': 296 | f = open(os.path.expanduser("~/.google")) 297 | username = f.readline().splitlines()[0] 298 | password = f.readline().splitlines()[0] 299 | f.close() 300 | commandMap = {} 301 | if os.path.isfile(os.path.expanduser("~/.tstat_commands")): 302 | f = open(os.path.expanduser("~/.tstat_commands")) 303 | for line in f.readlines(): 304 | key, value = line.split(":") 305 | commandMap[key] = value 306 | f.close() 307 | main(sys.argv[1], username=username, password=password, calName=sys.argv[2], commandMap=commandMap) 308 | --------------------------------------------------------------------------------