From 53a1dec569ce9b987dc6864fff2fece614cad772 Mon Sep 17 00:00:00 2001 From: Jerry Workman Date: Sun, 24 Jan 2016 22:16:21 -0500 Subject: [PATCH 1/9] Create README.md --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f4734d --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# Python-TStat +Python interface for Radio Thermostat wifi-enabled thermostats. + +#Usage: +t = TStat('1.2.3.4') Where 1.2.3.4 is your thermostat's IP address + +t.getCurrentTemp() Returns current temperature as a float + +t.setHeatPoint(68.0) Sets target temperature for heating to 68.0 + +... + +A simple cache based on retrieved URL and time of retrieval is included. +If you call TStat.getFanMode(), /tstat will be retrieved and the value +for fmode will be return. If you then call t.getTState(), a cached +value will already exist and tstate will be returned from that. + +You can change the time for cache expiration by calling + +t.setCacheExpiry(timeInSeconds). From 63a16c6f854f7a87440e79c8df352d337516c0d8 Mon Sep 17 00:00:00 2001 From: Jerry Workman Date: Sun, 24 Jan 2016 22:17:16 -0500 Subject: [PATCH 2/9] Delete API.py --- API.py | 206 --------------------------------------------------------- 1 file changed, 206 deletions(-) delete mode 100644 API.py diff --git a/API.py b/API.py deleted file mode 100644 index 9190e9c..0000000 --- a/API.py +++ /dev/null @@ -1,206 +0,0 @@ -#!/usr/bin/env python - -#Copyright (c) 2011, Paul Jennings -#All rights reserved. - -#Redistribution and use in source and binary forms, with or without -#modification, are permitted provided that the following conditions are met: -# -# * Redistributions of source code must retain the above copyright notice, -# this list of conditions and the following disclaimer. -# * Redistributions in binary form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in the -# documentation and/or other materials provided with the distribution. -# * The names of its contributors may not be used to endorse or promote -# products derived from this software without specific prior written -# permission. - -#THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -#AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -#IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -#ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE -#LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -#CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -#SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -#INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -#CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -#ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF -#THE POSSIBILITY OF SUCH DAMAGE. - -# API.py -# API definitions for Radio Thermostat wifi-enabled thermostats. - -# This file allows multiple APIs to be defined for different versions of the -# thermostat hardware/software. Currently there is only one API defined, for -# the 3M-50/CT30. Each API should be defined as a subclass of API. You must -# define models and versions to be a list of versions and models (as retrieved -# from the thermostat) that your API subclass supports. entries must be -# defined as a dict such that the keys are named pieces of data and the values -# are instances of APIEntry. (See APIv1 for an example.) -# -# Example APIEntry: -# 'fmode': APIEntry( -# [('/tstat/fmode', 'fmode'), ('/tstat', 'fmode')], -# [('/tstat/fmode', 'fmode')], -# {'0': 'Auto', '1': '??', '2':'On'} #TODO: Check these values -# ) -# APIEntry has three members: -# getters: A list of tuples where the first entry is a URL on the thermostat -# and the second entry is the JSON key used to retrieve the piece -# of data. In the above example, fmode can be retrieved either -# from /tstat/fmode using key fmode, or from /tstat using key -# fmode. Multiple values are allowed here to help support -# changes in hardware API. If fmode is removed from the /tstat -# output, the system will automatically fall back to /tstat/fmode. -# setters: A list of tuples in the same format as getters, used for setting -# values. -# valueMap: A dict of possible outputs and the human-readable value they -# should be mapped to. In the above example, fmode=0 is mapped to -# 'Auto', while fmode=2 is mapped to 'On'. -# -# Extending an existing API: -# Assume that in a new hardware/software revision, power usage data in KWH is -# available at /tstat/kwh. A new API could be defined as follows: -# -# class APIv2(APIv1): -# def __init__(self): -# self.entries['fmode'].getters = [('/tstat/kwh', 'kwh')] -# -# You would most likely also want to add access functions to TStat.py as -# well. - -class APIEntry: - def __init__(self, getters, setters, valueMap=None, usesJson=True): - self.getters = getters - self.setters = setters - self.valueMap = valueMap - self.usesJson = usesJson - -class API: - models = [] - successStrings = [] - entries = None - - def __getitem__(self, item): - return self.entries[item] - - def has_key(self, key): - return self.entries.has_key(key) - - entries = { - 'model': APIEntry( - [('/tstat/model', 'model')], - [] - ) - } - -class API_CT50v109(API): - models = ['CT50 V1.09'] - successStrings = [ - "Tstat Command Processed", - "Cloud updates have been suspended till reboot", - "Cloud updates activated" - ] - entries = { - 'fmode': APIEntry( - [('/tstat/fmode', 'fmode'), ('/tstat', 'fmode')], - [('/tstat/fmode', 'fmode')], - {0: 'Auto', 1: '??', 2:'On'} #TODO: Check these values - ), - 'tmode': APIEntry( - [('/tstat/tmode', 'tmode'), ('/tstat', 'tmode')], - [('/tstat/tmode', 'tmode')], - {0: 'Off', 1: 'On'} #TODO: Check these values - ), - 'temp': APIEntry( - [('/tstat', 'temp'), ('/temp', 'temp')], - [] - ), - 'override': APIEntry( - [('/tstat', 'override'), ('/tstat/info', 'override'), ('/tstat/override', 'override')], - [], - {0: False, 1: True} - ), - 'hold': APIEntry( - [('/tstat', 'hold'), ('/tstat/info', 'hold'), ('/tstat/hold', 'hold')], - [('/tstat/hold', 'hold')], - {0: False, 1: True} - ), - 't_heat': APIEntry( - [('/tstat/info', 't_heat'), ('/tstat/ttemp', 't_heat')], - [('/tstat/ttemp', 't_heat')] - ), - 't_cool': APIEntry( - [('/tstat/info', 't_cool'), ('/tstat/ttemp', 't_cool')], - [('/tstat/ttemp', 't_cool')] - ), - 'tstate': APIEntry( - [('/tstat', 'tstate')], - [], - {0: 'Off', 1: 'On'} - ), - 'fstate': APIEntry( - [('/tstat', 'fstate')], - [], - {0: 'Off', 1: 'On'} - ), - 'day': APIEntry( - [('/tstat', 'time/day')], - [], - {0: 'Monday', 1: 'Tuesday', 2: 'Wednesday', 3: 'Thursday', 4: 'Friday', 5: 'Saturday', 6: 'Sunday'} - ), - 'hour': APIEntry( - [('/tstat', 'time/hour')], - [] - ), - 'minute': APIEntry( - #[('/tstat', 'time/minute'), ('/tstat/time/minute', 'day')], - [('/tstat', 'time/minute')], - [] - ), - 'today_heat_runtime': APIEntry( - [('/tstat/datalog', 'today/heat_runtime')], - [] - ), - 'today_cool_runtime': APIEntry( - [('/tstat/datalog', 'today/cool_runtime')], - [] - ), - 'yesterday_heat_runtime': APIEntry( - [('/tstat/datalog', 'yesterday/heat_runtime')], - [] - ), - 'yesterday_cool_runtime': APIEntry( - [('/tstat/datalog', 'yesterday/cool_runtime')], - [] - ), - 'errstatus': APIEntry( - [('/tstat/errstatus', 'errstatus')], - [], - {0: 'OK'} - ), - 'model': APIEntry( - [('/tstat/model', 'model')], - [] - ), - 'power': APIEntry( - [('/tstat/power', 'power')], - [('/tstat/power', 'power')] - ), - 'cloud_mode': APIEntry( - [], - [('/cloud/mode', 'command')], - usesJson=False - ) - #'eventlog': #TODO - } - -class API_CT30v192(API_CT50v109): - models = ['CT30 V1.92'] - -APIs = [API_CT50v109, API_CT30v192] - -def getAPI(model): - for api in APIs: - if model in api.models: - return api() From 8d654c368e3cac34ec947ac3079caabcc36be0ec Mon Sep 17 00:00:00 2001 From: Jerry Workman Date: Sun, 24 Jan 2016 22:17:33 -0500 Subject: [PATCH 3/9] Delete TStat.py --- TStat.py | 417 ------------------------------------------------------- 1 file changed, 417 deletions(-) delete mode 100755 TStat.py diff --git a/TStat.py b/TStat.py deleted file mode 100755 index 1e620a1..0000000 --- a/TStat.py +++ /dev/null @@ -1,417 +0,0 @@ -#!/usr/bin/env python - -#Copyright (c) 2011, Paul Jennings -#All rights reserved. - -#Contributors: -# Billy J. West - -#Redistribution and use in source and binary forms, with or without -#modification, are permitted provided that the following conditions are met: -# -# * Redistributions of source code must retain the above copyright notice, -# this list of conditions and the following disclaimer. -# * Redistributions in binary form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in the -# documentation and/or other materials provided with the distribution. -# * The names of its contributors may not be used to endorse or promote -# products derived from this software without specific prior written -# permission. - -#THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -#AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -#IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -#ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE -#LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -#CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -#SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -#INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -#CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -#ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF -#THE POSSIBILITY OF SUCH DAMAGE. - -# TStat.py -# Python interface for Radio Thermostat wifi-enabled thermostats. - -# Usage: -# t = TStat('1.2.3.4') # Where 1.2.3.4 is your thermostat's IP address -# t.getCurrentTemp() # Returns current temperature as a float -# t.setHeatPoint(68.0) # Sets target temperature for heating to 68.0 -# ... -# -# -# A simple cache based on retrieved URL and time of retrieval is included. -# If you call TStat.getFanMode(), /tstat will be retrieved and the value -# for fmode will be return. If you then call t.getTState(), a cached -# value will already exist and tstate will be returned from that. -# -# You can change the time for cache expiration by calling -# t.setCacheExpiry(timeInSeconds). - -import datetime -import httplib -import urllib -import logging -import random -import socket -import time - -# For Python < 2.6, this json module: -# http://pypi.python.org/pypi/python-json -# will work. -try: - from json import read as loads - from json import write as dumps -except ImportError: - from json import loads - from json import dumps - -from API import * - -class CacheEntry: - def __init__(self, location, data): - self.location = location - self.data = data - self.time = datetime.datetime.now() - - def age(self): - return datetime.datetime.now()-self.time - -class TStat: - def __init__(self, address, cacheExpiry=5, api=None, logger=None, logLevel=None): - self.address = address - self.setCacheExpiry(cacheExpiry) - self.cache = {} - if logger is None: - if logLevel is None: - logLevel = logging.WARNING - logging.basicConfig(level=logLevel) - self.logger = logging.getLogger('TStat') - else: - self.logger = logger - if api is None: - self.api = API() - self.api = getAPI(self.getModel()) - time.sleep(2) - else: - self.api = api - - def setCacheExpiry(self, newExpiry): - self.cacheExpiry = datetime.timedelta(seconds=newExpiry) - - def _getConn(self): - """Used internally to get a connection to the tstat.""" - return httplib.HTTPConnection(self.address) - - def _post(self, key, value): - """Used internally to modify tstat settings (e.g. cloud mode).""" - - l = self.logger - - # Check for valid request - if not self.api.has_key(key): - l.error("%s does not exist in API" % key) - return False - - # Retrieve the mapping from api key to thermostat URL - entry = self.api[key] - l.debug("Got API entry: %s" % entry) - - try: - if len(entry.setters) < 1: - raise TypeError - except TypeError: - l.error("%s cannot be set (maybe readonly?)" % key) - return False - - # Check for valid values - if entry.valueMap is not None: - inverse = dict((v,k) for k, v in entry.valueMap.iteritems()) - if not inverse.has_key(value) and not entry.valueMap.has_key(value): - l.warning("Value '%s' may not be a valid value for '%s'" % (value, key)) - elif inverse.has_key(value): - value = inverse[value] - - for setter in entry.setters: - location = setter[0] - jsonKey = setter[1] - if entry.usesJson: - params = dumps({jsonKey: value}) - else: - params = urllib.urlencode({jsonKey: value}) - l.debug("Will send params: %s" % params) - - headers = {"Content-type": "application/x-www-form-urlencoded", "Accept": "text/plain"} - response = None - count = 0 - while response is None and count < 5: - try: - conn = self._getConn() - conn.request("POST", location, params, headers) - response = conn.getresponse() - except socket.error: - response = None - if response is None: - time.sleep(count*random.randint(0, 3)) - count = count + 1 - if response.status != 200: - l.error("Error %s while trying to set '%s' with '%s'" % (response.status, location, params)) - continue - data = response.read() - response.close() - - success = False - for s in self.api.successStrings: - if data.startswith(s): - success = True - break - - if not success: - l.error("Error trying to set '%s' with '%s': %s" % (location, params, data)) - l.debug("Response: %s" % data) - return True - - def _get(self, key, raw=False): - """Used internally to retrieve data from the tstat and process it with JSON if necessary.""" - - l = self.logger - l.debug("Requested: %s" % key) - - # Check for valid request - if not self.api.has_key(key): - #TODO: Error processing - l.debug("%s does not exist in API" % key) - return - - # Retrieve the mapping from api key to thermostat URL - entry = self.api[key] - l.debug("Got API entry: %s" % entry) - - # First check cache - newest = None - for getter in entry.getters: - location = getter[0] - jsonKey = getter[1] - if self.cache.has_key(location): - cacheEntry = self.cache[location] - l.debug("Found cache entry: %s" % cacheEntry) - if cacheEntry.age() < self.cacheExpiry: - l.debug("Entry is valid") - try: - if cacheEntry.age() < self.cache[newest[0]].age(): - l.debug("Entry is now newest entry") - newest = getter - except TypeError: - l.debug("Entry is first valid entry") - newest = getter - else: - l.debug("Entry is invalid (expired)") - - response = None - if newest is not None: - # At least one valid entry was found in the cache - l.debug("Using cached entry") - response = self.cache[newest[0]].data - else: - for getter in entry.getters: - # Either data was not cached or cache was expired - response = None - count = 0 - while response is None and count < 5: - try: - conn = self._getConn() - conn.request("GET", getter[0]) - response = conn.getresponse() - except socket.error: - response = None - if response is None: - time.sleep(count*random.randint(0, 10)) - count = count + 1 - if response.status != 200: - l.warning("Request for '%s' failed (error %s)" % (getter[0], response.status)) - response = None - continue - data = response.read() - response.close() - l.debug("Got response: %s" % data) - try: - response = loads(data) - if 'error_msg' not in response: - break - except: - l.warning("Some problem with response: %s" % data) - response = None - continue - - if response is None: - l.error("Unable to retrieve '%s' from any of %s" % (key, entry.getters)) - return - self.cache[getter[0]] = CacheEntry(getter[0], response) - - # Allow mappings to subdictionaries in json data - # e.g. 'today/heat_runtime' from '/tstat/datalog' - for key in getter[1].split("/"): - try: - response = response[key] - except: - pass - - if raw or entry.valueMap is None: - # User requested raw data or there is no value mapping - return response - - # User requested processing - l.debug("Mapping response") - try: - l.debug("%s --> %s" % (response, entry.valueMap[response])) - return entry.valueMap[response] - except: - l.debug("Didn't find '%s' in %s" % (response, entry.valueMap)) - return response - - def getCurrentTemp(self, raw=False): - """Returns current temperature measurement.""" - return self._get('temp', raw) - - def getTstatMode(self, raw=False): - """Returns current thermostat mode.""" - return self._get('tmode', raw) - - def setTstatMode(self, value): - """Sets thermostat mode.""" - return self._post('tmode', value) - - def getFanMode(self, raw=False): - """Returns current fan mode.""" - return self._get('fmode', raw) - - def setFanMode(self, value): - """Sets fan mode.""" - return self._post('fmode', value) - - def getOverride(self, raw=False): - """Returns current override setting""" - return self._get('override', raw) - - def getPower(self, raw=False): - """Returns power?""" - return self._get('power', raw) - - def setPower(self, value): - """Sets power?""" - return self._post('power', value) - - def getHoldState(self, raw=False): - """Returns current hold state.""" - return self._get('hold', raw) - - def setHoldState(self, value): - """Sets hold state.""" - return self._post('hold', value) - - def getHeatPoint(self, raw=False): - """Returns current set point for heat.""" - return self._get('t_heat', raw) - - def setHeatPoint(self, value): - """Sets point for heat.""" - return self._post('t_heat', value) - - def getCoolPoint(self, raw=False): - """Returns current set point for cooling.""" - return self._get('t_cool', raw) - - def setCoolPoint(self, value): - """Sets point for cooling.""" - return self._post('t_cool', value) - - def getSetPoints(self, raw=False): - """Returns both heating and cooling set points.""" - return (self.getHeatPoint(), self.getCoolPoint()) - - def getModel(self, raw=False): - """Returns the model of the thermostat.""" - return self._get('model', raw) - - def getTState(self, raw=False): - """Returns current thermostat state.""" - return self._get('tstate', raw) - - def getFanState(self, raw=False): - """Returns current fan state.""" - return self._get('fstate', raw) - - def getTime(self, raw=False): - """Returns current time.""" - return {'day': self._get('day'), 'hour': self._get('hour'), 'minute': self._get('minute')} - - def getHeatUsageToday(self, raw=False): - """Returns heat usage for today.""" - return self._get('today_heat_runtime') - - def getHeatUsageYesterday(self, raw=False): - """Returns heat usage for yesterday.""" - return self._get('yesterday_heat_runtime') - - def getCoolUsageToday(self, raw=False): - """Returns cool usage for today.""" - return self._get('today_cool_runtime') - - def getCoolUsageYesterday(self, raw=False): - """Returns cool usage for yesterday.""" - return self._get('yesterday_cool_runtime') - - def isOK(self): - """Returns true if thermostat reports that it is OK.""" - return self._get('errstatus') == 'OK' - - def getErrStatus(self): - """Returns current error code or 0 if everything is OK.""" - return self._get('errstatus') - - def getEventLog(self): - """Returns events?""" - pass - - def setCloudMode(self, value): - """Sets cloud mode to state.""" - return self._post("cloud_mode", value) - -def discover(): - import struct - import select - import re - - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) - sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2) - 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)) - - mreq = struct.pack("=4sl", socket.inet_aton("239.255.255.250"), socket.INADDR_ANY) - sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) - - sock.setblocking(0) - - ready = select.select([sock], [], [], 30) - data = None - if ready[0]: - data = sock.recv(4096).replace("\r\n", "\n") - - m = re.search("http://([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)/(.*?)$", data, re.MULTILINE) - - try: - return m.group(1) - except: - raise ValueError, "Didn't find any thermostats on the local network" - -def main(): - import sys - addr = discover() - - t = TStat(addr, api=API_CT50v109()) - for cmd in sys.argv[1:]: - result = eval("t.%s(raw=True)" % cmd) - #print "%s: %s" % (cmd, result) - print result - -if __name__ == '__main__': - main() From 9e7ade7ec0d7c9148f259d6846826a8f5a957d68 Mon Sep 17 00:00:00 2001 From: Jerry Workman Date: Sun, 24 Jan 2016 22:18:59 -0500 Subject: [PATCH 4/9] Delete TStatGcal.py --- TStatGcal.py | 307 --------------------------------------------------- 1 file changed, 307 deletions(-) delete mode 100644 TStatGcal.py diff --git a/TStatGcal.py b/TStatGcal.py deleted file mode 100644 index 7c1440e..0000000 --- a/TStatGcal.py +++ /dev/null @@ -1,307 +0,0 @@ -#!/usr/bin/env python - -#Copyright (c) 2011, Paul Jennings -#All rights reserved. - -#Redistribution and use in source and binary forms, with or without -#modification, are permitted provided that the following conditions are met: -# -# * Redistributions of source code must retain the above copyright notice, -# this list of conditions and the following disclaimer. -# * Redistributions in binary form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in the -# documentation and/or other materials provided with the distribution. -# * The names of its contributors may not be used to endorse or promote -# products derived from this software without specific prior written -# permission. - -#THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -#AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -#IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -#ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE -#LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -#CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -#SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -#INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -#CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -#ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF -#THE POSSIBILITY OF SUCH DAMAGE. - -VERSION = 1.0 - -# TStatGcal.py -# Script to pull commands from Google Calendar and update thermostat. -# -# Requirements: -# * gdata (http://code.google.com/p/gdata-python-client/) -# * ElementTree (http://effbot.org/zone/element-index.htm) -# * Python-TStat (same place you got this script) -# -# Usage: -# 1. Create a Google/GMail account (or Google Apps for domains). -# 2. Go to http://calendar.google.com -# 3. Create a calendar (called "Thermostat" for example). -# 4. Add events with titles of the form: -# "Heat 70" -- sets heat to 70 degrees -# "Cool 70" -- sets cool to 70 degrees -# "Fan On" -- forces fan on -# "Mode Off" -- forces system off -# 5. Run the following commands (assuming Unix/Linux system): -# echo "youraccount@gmail.com" >> ~/.google -# echo "yourpassword" >> ~/.google -# chmod 400 ~/.google -# (where "youraccount@gmail.com" is the account that you created in -# step 1 and "yourpassword" is your password) -# 6. Add the following to your crontab to run every 5 minutes or so: -# TStatGcal.py -# Where is the IP address of your thermostat -# and is the name of the calendar you created in -# step 3. -# -# Notes: -# In order to limit the chance that this script sets your -# thermostat to dangerous settings (e.g. too low or off during -# the winter, there are some override variables below: -# HEAT_MIN, HEAT_MAX: Minimum/maximum setting for heat -# COOL_MIN, COOL_MAX: Minimum/maximum setting for cool -# COMMANDS: What parts of the thermostat the script is -# allowed to control -# -# Set the HEAT/COOL variables to appropriate values for your -# situation. By default, this script will not set the -# thermostat mode (on/off/auto). You probably want to leave -# it on auto. This is to prevent a hacker (or a typo) from -# turning your furnace off during the winter. -# -# By default, this script does not disable cloud updates. -# That way, if this script does not run for some reason (e.g. -# if your computer crashes), you can still have a reasonable -# backup program running. When the cloud updates your thermostat, -# there may be a short period where the setting does not match -# what is on your calendar. If this behavior is undesirable, you -# can disable cloud updates. -# -# At the start time of your event, the script will set the -# the thermostat to the requested setting. The duration of the -# events on your calendar is ignored. For example, a simple -# program might look like this: -# 6:30 -- Heat 70 -# 8:00 -- Heat 60 -# 16:00 -- Heat 70 -# 22:00 -- Heat 60 -# In order to create this program in your calendar, you would need -# four events. If you create a "Heat 70" event that lasts from -# 6:30-22:00 and an overlapping "Heat 60" event that lasts from -# 8:00-16:00, you will effectively miss the "Heat 70" command at -# 16:00. Only the start time of the event is used. - -# Minimum and maximum values for heat and cool -# The script will never set values outside of this range -HEAT_MIN = 55 -HEAT_MAX = 80 -COOL_MIN = 70 -COOL_MAX = 100 - -# Valid commands -# Remove commands that you don't want the script to execute here -# mode in particular can be dangerous, because someone could create -# a 'mode off' command and turn your heat off in the winter. -#COMMANDS = ['Heat', 'Cool', 'Mode', 'Fan'] -COMMANDS = ['Heat', 'Cool', 'Fan'] - -PERIODS = ['Wake', 'Leave', 'Home', 'Sleep'] - -try: - from xml.etree import ElementTree # for Python 2.5 users -except ImportError: - from elementtree import ElementTree -import gdata.calendar.service -import gdata.service -import atom.service -import gdata.calendar - -import atom -import datetime -import getopt -import os -import sys -import string -import time - -import TStat - -def getCalendarService(username, password): - # Log in to Google - calendar_service = gdata.calendar.service.CalendarService() - calendar_service.email = username - calendar_service.password = password - calendar_service.source = "TStatGCal-%s" % VERSION - calendar_service.ProgrammaticLogin() - return calendar_service - -def main(tstatAddr, commandMap=None, username=None, password=None, calName="Thermostat"): - # Connect to thermostat - tstat = TStat.TStat(tstatAddr) - - # Command map is used to translate things like "Wake" into "Heat 70" - if commandMap is None: - commandMap = {} - - calendar_service = getCalendarService(username, password) - - # Create date range for event search - today = datetime.datetime.today() - gmt = time.gmtime() - gmtDiff = datetime.datetime(gmt[0], gmt[1], gmt[2], gmt[3], gmt[4]) - today - tomorrow = datetime.datetime.today()+datetime.timedelta(days=8) - - query = gdata.calendar.service.CalendarEventQuery() - query.start_min = "%04i-%02i-%02i" % (today.year, today.month, today.day) - query.start_max = "%04i-%02i-%02i" % (tomorrow.year, tomorrow.month, tomorrow.day) - - print "start_min:", query.start_min - print "start_max:", query.start_max - - # Look for a calendar called calName - feed = calendar_service.GetOwnCalendarsFeed() - for i, a_calendar in enumerate(feed.entry): - if a_calendar.title.text == calName: - query.feed = a_calendar.content.src - - if query.feed is None: - print "No calendar with name '%s' found" % calName - return - - # Search for the event that has passed but is closest to the current time - # There is probably a better way to do this... - closest = None - closestDT = None - closestWhen = None - closestEvent = None - closestCommand = None - closestValue = None - periods = {} - feed = calendar_service.CalendarQuery(query) - for i, an_event in enumerate(feed.entry): - #print '\t%s. %s' % (i, an_event.title.text,) - - # Try to map named time period into actual command - text = an_event.title.text.strip() - - if not text in PERIODS: - if commandMap.has_key(text): - text = commandMap[text] - - print "Translated %s into %s" % (an_event.title.text.strip(), text) - - # Skip events that are not valid commands - try: - (command, value) = text.splitlines()[0].split() - except: - command = text - if command not in COMMANDS: - print "Warning: '%s' is not a valid command" % text - continue - try: - float(value) - except: - if value not in ['Off', 'On', 'Auto']: - print "Warning: '%s' is not a valid command" % an_event.title.text - continue - for a_when in an_event.when: - d = a_when.start_time.split("T")[0] - t = a_when.start_time.split("T")[1].split(".")[0] - (year, month, day) = [int(p) for p in d.split("-")] - (hour, min, sec) = [int(p) for p in t.split(":")] - dt = datetime.datetime(year, month, day, hour, min, sec)-gmtDiff - #print "DT:", dt - d = dt-datetime.datetime.today() - #print "d.days:", d.days - - if text in PERIODS: - if not periods.has_key(dt.day): - periods[dt.day] = {} - periods[dt.day][text] = dt - else: - # Skip events that are in the future - if d.days >= 0: - continue - - if closest is None: - closest = d - closestDT = dt - closestWhen = a_when - closestEvent = an_event - closestCommand = command - closestValue = value - else: - if d.days < closest.days: - continue - if d.seconds > closest.seconds: - closest = d - closestDT = dt - closestWhen = a_when - closestEvent = an_event - closestCommand = command - closestValue = value - - print "Found periods:", periods - - # Handle programmed periods - periodCommands = {} - for day in range(0,7): - if not periodCommands.has_key(day): - periodCommands[day] = [] - for p in PERIODS: - - if periods.has_key(p): - periodCommands.append(int(periods[p].hour*60+periods[p].minute)) - periodCommands.append(int(commandMap[p].split()[-1])) - else: - periodCommands.append(periodCommands[-2]) - periodCommands.append(periodCommands[-2]) - - print "Commands:", periodCommands - - if closestEvent is None: - print "No events found" - return - - text = closestEvent.title.text - print "Closest event: %s at %s" % (text, closestDT) - #(command, value) = text.splitlines()[0].split() - command, value = (closestCommand, closestValue) - if command == 'Heat': - value = int(value) - if value >= HEAT_MIN and value <= HEAT_MAX: - print "Setting heat to %s" % int(value) - #tstat.setHeatPoint(value) - else: - print "Value out of acceptable heat range:", value - elif command == 'Cool': - value = int(value) - if value >= COOL_MIN and value <= COOL_MAX: - print "Setting cool to %s" % value - tstat.setCoolPoint(int(value)) - else: - print "Value out of acceptable cool range:", value - elif command == 'Fan': - print "Setting fan to %s" % value - tstat.setFanMode(value) - elif command == 'Mode': - print "Setting mode to %s" % value - tstat.setTstatMode(value) - -if __name__ == '__main__': - f = open(os.path.expanduser("~/.google")) - username = f.readline().splitlines()[0] - password = f.readline().splitlines()[0] - f.close() - commandMap = {} - if os.path.isfile(os.path.expanduser("~/.tstat_commands")): - f = open(os.path.expanduser("~/.tstat_commands")) - for line in f.readlines(): - key, value = line.split(":") - commandMap[key] = value - f.close() - main(sys.argv[1], username=username, password=password, calName=sys.argv[2], commandMap=commandMap) From 9c87aaff472b2af18581ddf410840a4af713093c Mon Sep 17 00:00:00 2001 From: Jerry Workman Date: Sun, 24 Jan 2016 22:22:08 -0500 Subject: [PATCH 5/9] Create __init__.py initial commit --- radiotstat/__init__.py | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 radiotstat/__init__.py diff --git a/radiotstat/__init__.py b/radiotstat/__init__.py new file mode 100644 index 0000000..7391493 --- /dev/null +++ b/radiotstat/__init__.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python + +from tstat import * From 1318f991062080cbbdbfe5dffea539659b9fa9ac Mon Sep 17 00:00:00 2001 From: Jerry Workman Date: Sun, 24 Jan 2016 22:25:02 -0500 Subject: [PATCH 6/9] Create api.py Add error message for unsupported thermostat models. Provide major model API definitions so that most models will at least have basic functionality. --- radiotstat/api.py | 214 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 radiotstat/api.py diff --git a/radiotstat/api.py b/radiotstat/api.py new file mode 100644 index 0000000..1a46dde --- /dev/null +++ b/radiotstat/api.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python + +#Copyright (c) 2011, Paul Jennings +#All rights reserved. + +#Redistribution and use in source and binary forms, with or without +#modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * The names of its contributors may not be used to endorse or promote +# products derived from this software without specific prior written +# permission. + +#THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +#AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +#IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +#ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +#LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +#CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +#SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +#INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +#CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +#ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF +#THE POSSIBILITY OF SUCH DAMAGE. + +# api.py +# API definitions for Radio Thermostat wifi-enabled thermostats. + +# This file allows multiple APIs to be defined for different versions of the +# thermostat hardware/software. Currently there is only one API defined, for +# the 3M-50/CT30. Each API should be defined as a subclass of API. You must +# define models and versions to be a list of versions and models (as retrieved +# from the thermostat) that your API subclass supports. entries must be +# defined as a dict such that the keys are named pieces of data and the values +# are instances of APIEntry. (See APIv1 for an example.) +# +# Example APIEntry: +# 'fmode': APIEntry( +# [('/tstat/fmode', 'fmode'), ('/tstat', 'fmode')], +# [('/tstat/fmode', 'fmode')], +# {'0': 'Auto', '1': '??', '2':'On'} #TODO: Check these values +# ) +# APIEntry has three members: +# getters: A list of tuples where the first entry is a URL on the thermostat +# and the second entry is the JSON key used to retrieve the piece +# of data. In the above example, fmode can be retrieved either +# from /tstat/fmode using key fmode, or from /tstat using key +# fmode. Multiple values are allowed here to help support +# changes in hardware API. If fmode is removed from the /tstat +# output, the system will automatically fall back to /tstat/fmode. +# setters: A list of tuples in the same format as getters, used for setting +# values. +# valueMap: A dict of possible outputs and the human-readable value they +# should be mapped to. In the above example, fmode=0 is mapped to +# 'Auto', while fmode=2 is mapped to 'On'. +# +# Extending an existing API: +# Assume that in a new hardware/software revision, power usage data in KWH is +# available at /tstat/kwh. A new API could be defined as follows: +# +# class APIv2(APIv1): +# def __init__(self): +# self.entries['fmode'].getters = [('/tstat/kwh', 'kwh')] +# +# You would most likely also want to add access functions to TStat.py as +# well. + +class APIEntry: + def __init__(self, getters, setters, valueMap=None, usesJson=True): + self.getters = getters + self.setters = setters + self.valueMap = valueMap + self.usesJson = usesJson + +class API: + models = [] + successStrings = [] + entries = None + + def __getitem__(self, item): + return self.entries[item] + + def has_key(self, key): + return self.entries.has_key(key) + + entries = { + 'model': APIEntry( + [('/tstat/model', 'model')], + [] + ) + } + +class API_CT50v109(API): + #since there is only one model defined lets allow all basic models to work + models = ['CT50 V1.09','CT30','CT50','CT80'] + successStrings = [ + "Tstat Command Processed", + "Cloud updates have been suspended till reboot", + "Cloud updates activated" + ] + entries = { + 'fmode': APIEntry( + [('/tstat/fmode', 'fmode'), ('/tstat', 'fmode')], + [('/tstat/fmode', 'fmode')], + {0: 'Auto', 1: '??', 2:'On'} #TODO: Check these values + ), + 'tmode': APIEntry( + [('/tstat/tmode', 'tmode'), ('/tstat', 'tmode')], + [('/tstat/tmode', 'tmode')], + {0: 'Off', 1: 'On'} #TODO: Check these values + ), + 'temp': APIEntry( + [('/tstat', 'temp'), ('/temp', 'temp')], + [] + ), + 'override': APIEntry( + [('/tstat', 'override'), ('/tstat/info', 'override'), ('/tstat/override', 'override')], + [], + {0: False, 1: True} + ), + 'hold': APIEntry( + [('/tstat', 'hold'), ('/tstat/info', 'hold'), ('/tstat/hold', 'hold')], + [('/tstat/hold', 'hold')], + {0: False, 1: True} + ), + 't_heat': APIEntry( + [('/tstat/info', 't_heat'), ('/tstat/ttemp', 't_heat')], + [('/tstat/ttemp', 't_heat')] + ), + 't_cool': APIEntry( + [('/tstat/info', 't_cool'), ('/tstat/ttemp', 't_cool')], + [('/tstat/ttemp', 't_cool')] + ), + 'tstate': APIEntry( + [('/tstat', 'tstate')], + [], + {0: 'Off', 1: 'On'} + ), + 'fstate': APIEntry( + [('/tstat', 'fstate')], + [], + {0: 'Off', 1: 'On'} + ), + 'day': APIEntry( + [('/tstat', 'time/day')], + [], + {0: 'Monday', 1: 'Tuesday', 2: 'Wednesday', 3: 'Thursday', 4: 'Friday', 5: 'Saturday', 6: 'Sunday'} + ), + 'hour': APIEntry( + [('/tstat', 'time/hour')], + [] + ), + 'minute': APIEntry( + #[('/tstat', 'time/minute'), ('/tstat/time/minute', 'day')], + [('/tstat', 'time/minute')], + [] + ), + 'today_heat_runtime': APIEntry( + [('/tstat/datalog', 'today/heat_runtime')], + [] + ), + 'today_cool_runtime': APIEntry( + [('/tstat/datalog', 'today/cool_runtime')], + [] + ), + 'yesterday_heat_runtime': APIEntry( + [('/tstat/datalog', 'yesterday/heat_runtime')], + [] + ), + 'yesterday_cool_runtime': APIEntry( + [('/tstat/datalog', 'yesterday/cool_runtime')], + [] + ), + 'errstatus': APIEntry( + [('/tstat/errstatus', 'errstatus')], + [], + {0: 'OK'} + ), + 'model': APIEntry( + [('/tstat/model', 'model')], + [] + ), + 'power': APIEntry( + [('/tstat/power', 'power')], + [('/tstat/power', 'power')] + ), + 'cloud_mode': APIEntry( + [], + [('/cloud/mode', 'command')], + usesJson=False + ) + #'eventlog': #TODO + } + +APIs = [API_CT50v109] + +def getAPI(model): + try: + for api in APIs: + if model in api.models: + return api() + else: + for m in api.models: + if model.startswith(m): + return api() + #fall thru + print 'Unsupported thermostat: ' + model + except: + print 'Unsupported thermostat: ' + model + From 05b15dc8f4d9e926e4661adc68d35fbf2d341ae8 Mon Sep 17 00:00:00 2001 From: Jerry Workman Date: Sun, 24 Jan 2016 22:28:10 -0500 Subject: [PATCH 7/9] Add additional error messages --- radiotstat/tstat.py | 421 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 421 insertions(+) create mode 100644 radiotstat/tstat.py diff --git a/radiotstat/tstat.py b/radiotstat/tstat.py new file mode 100644 index 0000000..28be87c --- /dev/null +++ b/radiotstat/tstat.py @@ -0,0 +1,421 @@ +#!/usr/bin/env python + +#Copyright (c) 2011, Paul Jennings +#All rights reserved. + +#Contributors: +# Billy J. West + +#Redistribution and use in source and binary forms, with or without +#modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * The names of its contributors may not be used to endorse or promote +# products derived from this software without specific prior written +# permission. + +#THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +#AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +#IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +#ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +#LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +#CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +#SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +#INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +#CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +#ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF +#THE POSSIBILITY OF SUCH DAMAGE. + +# tstat.py +# Python interface for Radio Thermostat wifi-enabled thermostats. + +# Usage: +# t = TStat('1.2.3.4') # Where 1.2.3.4 is your thermostat's IP address +# t.getCurrentTemp() # Returns current temperature as a float +# t.setHeatPoint(68.0) # Sets target temperature for heating to 68.0 +# ... +# +# +# A simple cache based on retrieved URL and time of retrieval is included. +# If you call TStat.getFanMode(), /tstat will be retrieved and the value +# for fmode will be return. If you then call t.getTState(), a cached +# value will already exist and tstate will be returned from that. +# +# You can change the time for cache expiration by calling +# t.setCacheExpiry(timeInSeconds). + +import datetime +import httplib +import urllib +import logging +import random +import socket +import time + +# For Python < 2.6, this json module: +# http://pypi.python.org/pypi/python-json +# will work. +try: + from json import read as loads + from json import write as dumps +except ImportError: + from json import loads + from json import dumps + +from api import * + +class CacheEntry: + def __init__(self, location, data): + self.location = location + self.data = data + self.time = datetime.datetime.now() + + def age(self): + return datetime.datetime.now()-self.time + +class TStat: + def __init__(self, address, cacheExpiry=5, api=None, logger=None, logLevel=None): + self.address = address + self.setCacheExpiry(cacheExpiry) + self.cache = {} + if logger is None: + if logLevel is None: + logLevel = logging.WARNING + logging.basicConfig(level=logLevel) + self.logger = logging.getLogger(__name__) + else: + self.logger = logger + if api is None: + self.api = API() + self.api = getAPI(self.getModel()) + time.sleep(2) + else: + self.api = api + + def setCacheExpiry(self, newExpiry): + self.cacheExpiry = datetime.timedelta(seconds=newExpiry) + + def _getConn(self): + """Used internally to get a connection to the tstat.""" + return httplib.HTTPConnection(self.address) + + def _post(self, key, value): + """Used internally to modify tstat settings (e.g. cloud mode).""" + + l = self.logger + + # Check for valid request + if not self.api.has_key(key): + l.error("%s does not exist in API" % key) + return False + + # Retrieve the mapping from api key to thermostat URL + entry = self.api[key] + l.debug("Got API entry: %s" % entry) + + try: + if len(entry.setters) < 1: + raise TypeError + except TypeError: + l.error("%s cannot be set (maybe readonly?)" % key) + return False + + # Check for valid values + if entry.valueMap is not None: + inverse = dict((v,k) for k, v in entry.valueMap.iteritems()) + if not inverse.has_key(value) and not entry.valueMap.has_key(value): + l.warning("Value '%s' may not be a valid value for '%s'" % (value, key)) + elif inverse.has_key(value): + value = inverse[value] + + for setter in entry.setters: + location = setter[0] + jsonKey = setter[1] + if entry.usesJson: + params = dumps({jsonKey: value}) + else: + params = urllib.urlencode({jsonKey: value}) + l.debug("Will send params: %s" % params) + + headers = {"Content-type": "application/x-www-form-urlencoded", "Accept": "text/plain"} + response = None + count = 0 + while response is None and count < 5: + try: + conn = self._getConn() + conn.request("POST", location, params, headers) + response = conn.getresponse() + except socket.error: + response = None + if response is None: + time.sleep(count*random.randint(0, 3)) + count = count + 1 + if response.status != 200: + l.error("Error %s while trying to set '%s' with '%s'" % (response.status, location, params)) + continue + data = response.read() + response.close() + + success = False + for s in self.api.successStrings: + if data.startswith(s): + success = True + break + + if not success: + l.error("Error trying to set '%s' with '%s': %s" % (location, params, data)) + l.debug("Response: %s" % data) + return True + + def _get(self, key, raw=False): + """Used internally to retrieve data from the tstat and process it with JSON if necessary.""" + + l = self.logger + l.debug("Requested: %s" % key) + if not self.api: + raise Exception("No API") + # Check for valid request + if not self.api.has_key(key): + #TODO: Error processing + l.debug("%s does not exist in API" % key) + return + + # Retrieve the mapping from api key to thermostat URL + entry = self.api[key] + l.debug("Got API entry: %s" % entry) + + # First check cache + newest = None + for getter in entry.getters: + location = getter[0] + jsonKey = getter[1] + if self.cache.has_key(location): + cacheEntry = self.cache[location] + l.debug("Found cache entry: %s" % cacheEntry) + if cacheEntry.age() < self.cacheExpiry: + l.debug("Entry is valid") + try: + if cacheEntry.age() < self.cache[newest[0]].age(): + l.debug("Entry is now newest entry") + newest = getter + except TypeError: + l.debug("Entry is first valid entry") + newest = getter + else: + l.debug("Entry is invalid (expired)") + + response = None + if newest is not None: + # At least one valid entry was found in the cache + l.debug("Using cached entry") + response = self.cache[newest[0]].data + else: + for getter in entry.getters: + # Either data was not cached or cache was expired + response = None + count = 0 + while response is None and count < 5: + try: + conn = self._getConn() + conn.request("GET", getter[0]) + response = conn.getresponse() + except socket.error: + response = None + if response is None: + time.sleep(count*random.randint(0, 10)) + count = count + 1 + if response.status != 200: + l.warning("Request for '%s' failed (error %s)" % (getter[0], response.status)) + response = None + continue + data = response.read() + response.close() + l.debug("Got response: %s" % data) + try: + response = loads(data) + if 'error_msg' not in response: + break + except: + l.warning("Some problem with response: %s" % data) + response = None + continue + + if response is None: + l.error("Unable to retrieve '%s' from any of %s" % (key, entry.getters)) + return + self.cache[getter[0]] = CacheEntry(getter[0], response) + + # Allow mappings to subdictionaries in json data + # e.g. 'today/heat_runtime' from '/tstat/datalog' + for key in getter[1].split("/"): + try: + response = response[key] + except: + pass + + if raw or entry.valueMap is None: + # User requested raw data or there is no value mapping + return response + + # User requested processing + l.debug("Mapping response") + try: + l.debug("%s --> %s" % (response, entry.valueMap[response])) + return entry.valueMap[response] + except: + l.debug("Didn't find '%s' in %s" % (response, entry.valueMap)) + return response + + def getCurrentTemp(self, raw=False): + """Returns current temperature measurement.""" + return self._get('temp', raw) + + def getTstatMode(self, raw=False): + """Returns current thermostat mode.""" + return self._get('tmode', raw) + + def setTstatMode(self, value): + """Sets thermostat mode.""" + return self._post('tmode', value) + + def getFanMode(self, raw=False): + """Returns current fan mode.""" + return self._get('fmode', raw) + + def setFanMode(self, value): + """Sets fan mode.""" + return self._post('fmode', value) + + def getOverride(self, raw=False): + """Returns current override setting""" + return self._get('override', raw) + + def getPower(self, raw=False): + """Returns power?""" + return self._get('power', raw) + + def setPower(self, value): + """Sets power?""" + return self._post('power', value) + + def getHoldState(self, raw=False): + """Returns current hold state.""" + return self._get('hold', raw) + + def setHoldState(self, value): + """Sets hold state.""" + return self._post('hold', value) + + def getHeatPoint(self, raw=False): + """Returns current set point for heat.""" + return self._get('t_heat', raw) + + def setHeatPoint(self, value): + """Sets point for heat.""" + return self._post('t_heat', value) + + def getCoolPoint(self, raw=False): + """Returns current set point for cooling.""" + return self._get('t_cool', raw) + + def setCoolPoint(self, value): + """Sets point for cooling.""" + return self._post('t_cool', value) + + def getSetPoints(self, raw=False): + """Returns both heating and cooling set points.""" + return (self.getHeatPoint(), self.getCoolPoint()) + + def getModel(self, raw=False): + """Returns the model of the thermostat.""" + return self._get('model', raw) + + def getTState(self, raw=False): + """Returns current thermostat state.""" + return self._get('tstate', raw) + + def getFanState(self, raw=False): + """Returns current fan state.""" + return self._get('fstate', raw) + + def getTime(self, raw=False): + """Returns current time.""" + return {'day': self._get('day'), 'hour': self._get('hour'), 'minute': self._get('minute')} + + def getHeatUsageToday(self, raw=False): + """Returns heat usage for today.""" + return self._get('today_heat_runtime') + + def getHeatUsageYesterday(self, raw=False): + """Returns heat usage for yesterday.""" + return self._get('yesterday_heat_runtime') + + def getCoolUsageToday(self, raw=False): + """Returns cool usage for today.""" + return self._get('today_cool_runtime') + + def getCoolUsageYesterday(self, raw=False): + """Returns cool usage for yesterday.""" + return self._get('yesterday_cool_runtime') + + def isOK(self): + """Returns true if thermostat reports that it is OK.""" + return self._get('errstatus') == 'OK' + + def getErrStatus(self): + """Returns current error code or 0 if everything is OK.""" + return self._get('errstatus') + + def getEventLog(self): + """Returns events?""" + pass + + def setCloudMode(self, value): + """Sets cloud mode to state.""" + return self._post("cloud_mode", value) + +def discover(): + import struct + import select + import re + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2) + 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)) + + mreq = struct.pack("=4sl", socket.inet_aton("239.255.255.250"), socket.INADDR_ANY) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) + + sock.setblocking(0) + + ready = select.select([sock], [], [], 30) + data = None + if ready[0]: + data = sock.recv(4096).replace("\r\n", "\n") + + m = re.search("http://([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)/(.*?)$", data, re.MULTILINE) + + try: + return m.group(1) + except: + raise ValueError, "Didn't find any thermostats on the local network" + +def main(): + import sys + addr = discover() + + t = TStat(addr, api=API_CT50v109()) + if len(sys.argv) == 1: + print "Usage: " + sys.argv[0] + " command(s)" + print "Example: " + sys.argv[0] + " getCurrentTemp" + for cmd in sys.argv[1:]: + result = eval("t.%s(raw=True)" % cmd) + #print "%s: %s" % (cmd, result) + print result + +if __name__ == '__main__': + main() From 5d4971c169e79a77f8451d92855e6236362ea198 Mon Sep 17 00:00:00 2001 From: Jerry Workman Date: Sun, 24 Jan 2016 22:29:22 -0500 Subject: [PATCH 8/9] No changes --- radiotstat/tstat_gcal.py | 307 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 radiotstat/tstat_gcal.py diff --git a/radiotstat/tstat_gcal.py b/radiotstat/tstat_gcal.py new file mode 100644 index 0000000..98ac7bd --- /dev/null +++ b/radiotstat/tstat_gcal.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python + +#Copyright (c) 2011, Paul Jennings +#All rights reserved. + +#Redistribution and use in source and binary forms, with or without +#modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * The names of its contributors may not be used to endorse or promote +# products derived from this software without specific prior written +# permission. + +#THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +#AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +#IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +#ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +#LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +#CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +#SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +#INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +#CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +#ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF +#THE POSSIBILITY OF SUCH DAMAGE. + +VERSION = 1.0 + +# tstat_gcal.py +# Script to pull commands from Google Calendar and update thermostat. +# +# Requirements: +# * gdata (http://code.google.com/p/gdata-python-client/) +# * ElementTree (http://effbot.org/zone/element-index.htm) +# * Python-TStat (same place you got this script) +# +# Usage: +# 1. Create a Google/GMail account (or Google Apps for domains). +# 2. Go to http://calendar.google.com +# 3. Create a calendar (called "Thermostat" for example). +# 4. Add events with titles of the form: +# "Heat 70" -- sets heat to 70 degrees +# "Cool 70" -- sets cool to 70 degrees +# "Fan On" -- forces fan on +# "Mode Off" -- forces system off +# 5. Run the following commands (assuming Unix/Linux system): +# echo "youraccount@gmail.com" >> ~/.google +# echo "yourpassword" >> ~/.google +# chmod 400 ~/.google +# (where "youraccount@gmail.com" is the account that you created in +# step 1 and "yourpassword" is your password) +# 6. Add the following to your crontab to run every 5 minutes or so: +# TStatGcal.py +# Where is the IP address of your thermostat +# and is the name of the calendar you created in +# step 3. +# +# Notes: +# In order to limit the chance that this script sets your +# thermostat to dangerous settings (e.g. too low or off during +# the winter, there are some override variables below: +# HEAT_MIN, HEAT_MAX: Minimum/maximum setting for heat +# COOL_MIN, COOL_MAX: Minimum/maximum setting for cool +# COMMANDS: What parts of the thermostat the script is +# allowed to control +# +# Set the HEAT/COOL variables to appropriate values for your +# situation. By default, this script will not set the +# thermostat mode (on/off/auto). You probably want to leave +# it on auto. This is to prevent a hacker (or a typo) from +# turning your furnace off during the winter. +# +# By default, this script does not disable cloud updates. +# That way, if this script does not run for some reason (e.g. +# if your computer crashes), you can still have a reasonable +# backup program running. When the cloud updates your thermostat, +# there may be a short period where the setting does not match +# what is on your calendar. If this behavior is undesirable, you +# can disable cloud updates. +# +# At the start time of your event, the script will set the +# the thermostat to the requested setting. The duration of the +# events on your calendar is ignored. For example, a simple +# program might look like this: +# 6:30 -- Heat 70 +# 8:00 -- Heat 60 +# 16:00 -- Heat 70 +# 22:00 -- Heat 60 +# In order to create this program in your calendar, you would need +# four events. If you create a "Heat 70" event that lasts from +# 6:30-22:00 and an overlapping "Heat 60" event that lasts from +# 8:00-16:00, you will effectively miss the "Heat 70" command at +# 16:00. Only the start time of the event is used. + +# Minimum and maximum values for heat and cool +# The script will never set values outside of this range +HEAT_MIN = 55 +HEAT_MAX = 80 +COOL_MIN = 70 +COOL_MAX = 100 + +# Valid commands +# Remove commands that you don't want the script to execute here +# mode in particular can be dangerous, because someone could create +# a 'mode off' command and turn your heat off in the winter. +#COMMANDS = ['Heat', 'Cool', 'Mode', 'Fan'] +COMMANDS = ['Heat', 'Cool', 'Fan'] + +PERIODS = ['Wake', 'Leave', 'Home', 'Sleep'] + +try: + from xml.etree import ElementTree # for Python 2.5 users +except ImportError: + from elementtree import ElementTree +import gdata.calendar.service +import gdata.service +import atom.service +import gdata.calendar + +import atom +import datetime +import getopt +import os +import sys +import string +import time + +import TStat + +def getCalendarService(username, password): + # Log in to Google + calendar_service = gdata.calendar.service.CalendarService() + calendar_service.email = username + calendar_service.password = password + calendar_service.source = "TStatGCal-%s" % VERSION + calendar_service.ProgrammaticLogin() + return calendar_service + +def main(tstatAddr, commandMap=None, username=None, password=None, calName="Thermostat"): + # Connect to thermostat + tstat = TStat.TStat(tstatAddr) + + # Command map is used to translate things like "Wake" into "Heat 70" + if commandMap is None: + commandMap = {} + + calendar_service = getCalendarService(username, password) + + # Create date range for event search + today = datetime.datetime.today() + gmt = time.gmtime() + gmtDiff = datetime.datetime(gmt[0], gmt[1], gmt[2], gmt[3], gmt[4]) - today + tomorrow = datetime.datetime.today()+datetime.timedelta(days=8) + + query = gdata.calendar.service.CalendarEventQuery() + query.start_min = "%04i-%02i-%02i" % (today.year, today.month, today.day) + query.start_max = "%04i-%02i-%02i" % (tomorrow.year, tomorrow.month, tomorrow.day) + + print "start_min:", query.start_min + print "start_max:", query.start_max + + # Look for a calendar called calName + feed = calendar_service.GetOwnCalendarsFeed() + for i, a_calendar in enumerate(feed.entry): + if a_calendar.title.text == calName: + query.feed = a_calendar.content.src + + if query.feed is None: + print "No calendar with name '%s' found" % calName + return + + # Search for the event that has passed but is closest to the current time + # There is probably a better way to do this... + closest = None + closestDT = None + closestWhen = None + closestEvent = None + closestCommand = None + closestValue = None + periods = {} + feed = calendar_service.CalendarQuery(query) + for i, an_event in enumerate(feed.entry): + #print '\t%s. %s' % (i, an_event.title.text,) + + # Try to map named time period into actual command + text = an_event.title.text.strip() + + if not text in PERIODS: + if commandMap.has_key(text): + text = commandMap[text] + + print "Translated %s into %s" % (an_event.title.text.strip(), text) + + # Skip events that are not valid commands + try: + (command, value) = text.splitlines()[0].split() + except: + command = text + if command not in COMMANDS: + print "Warning: '%s' is not a valid command" % text + continue + try: + float(value) + except: + if value not in ['Off', 'On', 'Auto']: + print "Warning: '%s' is not a valid command" % an_event.title.text + continue + for a_when in an_event.when: + d = a_when.start_time.split("T")[0] + t = a_when.start_time.split("T")[1].split(".")[0] + (year, month, day) = [int(p) for p in d.split("-")] + (hour, min, sec) = [int(p) for p in t.split(":")] + dt = datetime.datetime(year, month, day, hour, min, sec)-gmtDiff + #print "DT:", dt + d = dt-datetime.datetime.today() + #print "d.days:", d.days + + if text in PERIODS: + if not periods.has_key(dt.day): + periods[dt.day] = {} + periods[dt.day][text] = dt + else: + # Skip events that are in the future + if d.days >= 0: + continue + + if closest is None: + closest = d + closestDT = dt + closestWhen = a_when + closestEvent = an_event + closestCommand = command + closestValue = value + else: + if d.days < closest.days: + continue + if d.seconds > closest.seconds: + closest = d + closestDT = dt + closestWhen = a_when + closestEvent = an_event + closestCommand = command + closestValue = value + + print "Found periods:", periods + + # Handle programmed periods + periodCommands = {} + for day in range(0,7): + if not periodCommands.has_key(day): + periodCommands[day] = [] + for p in PERIODS: + + if periods.has_key(p): + periodCommands.append(int(periods[p].hour*60+periods[p].minute)) + periodCommands.append(int(commandMap[p].split()[-1])) + else: + periodCommands.append(periodCommands[-2]) + periodCommands.append(periodCommands[-2]) + + print "Commands:", periodCommands + + if closestEvent is None: + print "No events found" + return + + text = closestEvent.title.text + print "Closest event: %s at %s" % (text, closestDT) + #(command, value) = text.splitlines()[0].split() + command, value = (closestCommand, closestValue) + if command == 'Heat': + value = int(value) + if value >= HEAT_MIN and value <= HEAT_MAX: + print "Setting heat to %s" % int(value) + #tstat.setHeatPoint(value) + else: + print "Value out of acceptable heat range:", value + elif command == 'Cool': + value = int(value) + if value >= COOL_MIN and value <= COOL_MAX: + print "Setting cool to %s" % value + tstat.setCoolPoint(int(value)) + else: + print "Value out of acceptable cool range:", value + elif command == 'Fan': + print "Setting fan to %s" % value + tstat.setFanMode(value) + elif command == 'Mode': + print "Setting mode to %s" % value + tstat.setTstatMode(value) + +if __name__ == '__main__': + f = open(os.path.expanduser("~/.google")) + username = f.readline().splitlines()[0] + password = f.readline().splitlines()[0] + f.close() + commandMap = {} + if os.path.isfile(os.path.expanduser("~/.tstat_commands")): + f = open(os.path.expanduser("~/.tstat_commands")) + for line in f.readlines(): + key, value = line.split(":") + commandMap[key] = value + f.close() + main(sys.argv[1], username=username, password=password, calName=sys.argv[2], commandMap=commandMap) From 098eb462d5da627db441533349bb6398a3b11b99 Mon Sep 17 00:00:00 2001 From: Jerry Workman Date: Sun, 24 Jan 2016 22:30:46 -0500 Subject: [PATCH 9/9] Added to allow installation to system / site python library --- setup.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 setup.py diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..cceb2c4 --- /dev/null +++ b/setup.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python + +from distutils.core import setup + +setup(name='radiotstat', + version='1.0', + description='Python interface for Radio Thermostat wifi-enabled thermostats.', + author='Paul Jennings', + author_email='pjennings-tstat@pjennings.net', + url='https://github.com/pjennings/Python-TStat', + packages=['radiotstat'], + )