pax_global_header00006660000000000000000000000064140014360310014502gustar00rootroot0000000000000052 comment=614a2516b8bdb1d3c1b8cf3c641a9034c5adcf55 mhrivnak-radiotherm-614a251/000077500000000000000000000000001400143603100157225ustar00rootroot00000000000000mhrivnak-radiotherm-614a251/.github/000077500000000000000000000000001400143603100172625ustar00rootroot00000000000000mhrivnak-radiotherm-614a251/.github/workflows/000077500000000000000000000000001400143603100213175ustar00rootroot00000000000000mhrivnak-radiotherm-614a251/.github/workflows/publish.yml000066400000000000000000000012771400143603100235170ustar00rootroot00000000000000name: Publish on: push: tags: - '*' jobs: release: name: Publish runs-on: ubuntu-latest steps: - uses: actions/checkout@master - name: Set up Python uses: actions/setup-python@v1 with: python-version: 3.9 - name: Build sdist run: python setup.py sdist - name: Publish uses: pypa/gh-action-pypi-publish@master with: password: ${{ secrets.TEST_PYPI_API_TOKEN }} repository_url: https://test.pypi.org/legacy/ - name: Publish distribution 📦 to PyPI if: startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@master with: password: ${{ secrets.PYPI_API_TOKEN }} mhrivnak-radiotherm-614a251/.github/workflows/unit-tests.yml000066400000000000000000000011271400143603100241620ustar00rootroot00000000000000name: unit tests on: pull_request: branches: - '*' jobs: test: runs-on: ubuntu-latest strategy: matrix: python-version: [2.7, 3.6, 3.7, 3.8, 3.9] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install mock for python 2.7 run: "if [ $(python -c 'import sys; print(sys.version_info < (3, 0))') = True ]; then pip install mock; fi" - name: run tests run: python -m unittest discover mhrivnak-radiotherm-614a251/.gitignore000066400000000000000000000003501400143603100177100ustar00rootroot00000000000000*.py[co] # Packages *.egg *.egg-info dist build eggs parts bin var sdist develop-eggs .installed.cfg # Installer logs pip-log.txt # Unit test / coverage reports .coverage .tox #Translations *.mo #Mr Developer .mr.developer.cfg mhrivnak-radiotherm-614a251/LICENSE.txt000066400000000000000000000027441400143603100175540ustar00rootroot00000000000000Copyright (c) 2012 Michael Hrivnak and individual contributors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. 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. 3. Neither the name of Michael Hrivnak nor the names of its contributors may 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 OWNER 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. mhrivnak-radiotherm-614a251/MANIFEST.in000066400000000000000000000000231400143603100174530ustar00rootroot00000000000000include README.rst mhrivnak-radiotherm-614a251/README.rst000066400000000000000000000153131400143603100174140ustar00rootroot00000000000000Introduction ============ This is a library for communicating with a wifi-enabled home thermostat made by `Radio Thermostat Company of America `_. At the time of writing, this includes the CT30, CT80, and the `Filtrete 3M50 `_, which is made by Radio Thermostat but rebranded and sold at Home Depot in the US. Radio Thermostat Company of America was not involved in the creation of this software and has not sanctioned or endorsed it in any way. License ======= This software is available under a BSD-style license. Please see LICENSE.txt. Author ====== Michael Hrivnak is a professional software engineer who is passionate about open source software and reducing energy consumption. Features ======== - *Auto-Discovery* Your thermostat can be automatically detected, so there is no need to enter an IP address or domain name. - *Comprehensive* Nearly every documented feature that works is implemented in this library. - *Python 3 Support* This works in all Python versions from 2.7 up. - *Tested* There is good test coverage using true unit tests. Usage ===== Getting Started --------------- Import the library, and away we go. >>> import radiotherm >>> tstat = radiotherm.get_thermostat('192.168.0.2') >>> tstat.temp {'raw': 72.5} If you have only one thermostat on your network, you can do auto-discovery by omitting the address. >>> tstat = radiotherm.get_thermostat() Human-Readable Values --------------------- The value from the thermostat is always returned under the key 'raw'. For fields that support human-readable values, there will be a key 'human'. >>> tstat.tmode {'raw': 2, 'human': 'Cool'} API === The library centers around the Thermostat class, whose attributes are closely related to the attributes defined in Radio Thermostat's API doccumentation. For example, /tstat/temp in this case maps to the "temp" attribute on your Thermostat instance. Device Versions --------------- Supported models: - CT30 v1.75 - CT30 v1.92 - CT30 v1.94 - CT30 v1.99 - CT50 V1.09 - CT50 V1.88 - CT50 V1.92 - CT50 V1.94 - CT80 Rev B1 V1.00 - CT80 Rev B2 V1.00 - CT80 Rev B2 V1.03 - CT80 Rev B2 V1.09 Since I only have access to the 3M50 (which reports its model as "CT50 V1.94"), that is the model that most development has occured with. Do you have another model? Let me know, and let's collaborate to get it supported! New models that are derivatives of the CT30 or CT80 should be detected automatically and basic functionality should work. If you find this is not the case, it can be supported easily by subclassing either the CT30 or CT80 classes, depending on the thermostat model. Most of the API should work on all devices, but there are apparently some differences that will need to be accounted for. Long-term, I expect for those common features to be implemented on CommonThermostat, while device-specific deviations will be implemented on subclasses, such as the CT50v194 class. Supported Features ------------------ Many of the features documented in the manufacturer's API reference do not seem to work. For example, /tstat/save_energy seems to be broken. This library should not implement those broken features. Also, there are some features, like humidity control, that are only available on specific devices. Isn't there already a python library? ===================================== Yes! Many thanks to Paul Jenning for creating `Python-TStat `_. The existance of his library was a substantial motivation for me to buy this device. Why create a new library? ------------------------- I quickly identified some areas of Python-TStat that I wanted to improve. That led me to realize that there were conceptual differences between that library and my idea of what I wanted to use in my own projects. - *Thin wrapper*. I want API libraries to be thin. Python-TStat does automatic result caching by default, which I personally don't want. - *PEP-8*. I think it's important, and it would have taken a lot of work to make Python-TStat compliant. - *Testing*. It's important to me that code be tested, and Python-TStat had no tests. Proper unit-testing is much easier to do when the code was written from the beginning with it in mind, so that made it more convenient to start over. - *Simplicity*. My approach to defining the API in python is inspired by Django's model API, and I think it's resulted in easy-to-use and easy-to-read code. - *Less Code*. I've implemented a feature set very similar to that of Python-TStat (minus caching). Not counting comments, doc blocks or blank lines, this library (at the time of initial release) has 201 lines of code, whereas Python-TStat has 349. - *Python 3 Support*. This is also important to me. This library supports all python versions from 2.6 up. All of that said, Python-TStat is a good library that works well. I just decided that the quickest way for me to achieve the above goals was to start from scratch, which was relatively painless since the device's API isn't very complicated or large. Release Notes ============= 2.1.0 ----- - `Shorten http request timeout `_ for cases where the thermostat does not respond. (@vinnyfuria) - Drop python 2.6 support - Use GitHub Actions instead of Travis-CI (@mhrivnak) 2.0.0 ----- - Add support for the LED API (David Rasch) - Allow unknown thermostats to work properly instead of failing (@JerryWorkman, @craftyguy) - Handle transient thermostat errors (@tubaman) 1.4.1 ----- Minor update to bump version in setup.py 1.4 --- Several new models were added with thanks to the corresponding contributors! CT80 Rev B1 V1.00 - Eamon Doyle CT80 Rev B2 V1.00 - Clayton Craft Additional changes: - Add 'model' parameter to `get_thermostat()` - skimj - Add program_mode for CT80 - skimj 1.3 --- Several models were added with thanks to the corresponding contributors! CT30 v1.75 - Albert Lee CT30 V1.94 - billy1 CT30 v1.99 - Adam Fazzari CT50 V1.92 - mdingman CT80 Rev B2 V1.09 - Steve Bauer Thanks also to Albert Lee for adding remote temperature support, energy LED support, plus support for the "lock_mode" and "simple_mode". 1.2 --- Thanks to a contribution from Nick Pegg, the CT80 Rev B2 V1.03 is now supported. Support for `Travis CI `_ was added, so all pushes to the GitHub repository are automatically tested with multiple python versions. 1.1 --- Thanks to community contributions, this library now supports the CT50 V1.09 and CT50 V1.88. No changes were made except to certify that all functionality works with these models, and add a new subclass for each. 1.0 --- Initial release! This supports only the CT50 V1.94 mhrivnak-radiotherm-614a251/REQUIREMENTS.txt000066400000000000000000000000161400143603100204030ustar00rootroot00000000000000python >= 2.7 mhrivnak-radiotherm-614a251/radiotherm/000077500000000000000000000000001400143603100200605ustar00rootroot00000000000000mhrivnak-radiotherm-614a251/radiotherm/__init__.py000066400000000000000000000043161400143603100221750ustar00rootroot00000000000000from .thermostat import ( Thermostat, CommonThermostat, CT30v175, CT30v192, CT30v194, CT30v199, CT50v109, CT50v188, CT50v192, CT50v194, CT80RevB1v100, CT80RevB2v100, CT80RevB2v103, CT80RevB2v109, CT80RevB, CT30, CT50, CT80 ) from . import discover from . import fields THERMOSTATS = ( CT30v175, CT30v192, CT30v194, CT30v199, CT50v109, CT50v188, CT50v192, CT50v194, CT80RevB1v100, CT80RevB2v100, CT80RevB2v103, CT80RevB2v109, CT80RevB, CT80, CT50, CT30 ) def get_thermostat_class(model): """ :param model: string representation of the thermostat's model, in whatever format the thermostat itself returns. :type model: str :returns: subclass of CommonThermostat, or None if there is not a matching subclass found in THERMOSTATS. """ # Look for exact matches first for thermostat in THERMOSTATS: if issubclass(thermostat, Thermostat) and thermostat.MODEL == model: return thermostat # Look for partial matches next for thermostat in THERMOSTATS: if (issubclass(thermostat, Thermostat) and thermostat.MODEL in model): return thermostat def get_thermostat(host_address=None, model=None): """ If a host_address is not passed, auto-discovery will happen. Auto-discovery will only succeed then exactly 1 thermostat is on your network. :param host_address: optional address for a thermostat. This can be an IP address or domain name. :param model: optional string representing the model and version number. Available from API resource /tstat/model :returns: instance of a CommonThermostat subclass, or None if a matching subclass cannot be found. """ if host_address is None: host_address = discover.discover_address() if model is None: initial = CommonThermostat(host_address) model = initial.model.get('raw') thermostat_class = get_thermostat_class(model) if thermostat_class is not None: return thermostat_class(host_address) mhrivnak-radiotherm-614a251/radiotherm/discover.py000066400000000000000000000040701400143603100222510ustar00rootroot00000000000000import socket import struct IP_ADDRESS = '239.255.255.250' PORT = 1900 MESSAGE = """TYPE: WM-DISCOVER\r\nVERSION: 1.0\r\n\r\nservices: com.marvell.wm.system*\r\n\r\n""".encode('utf-8') def discover_address(): """ The example discovery program provided by Radio Thermostat sets the IP packet's TTL to 3. I'm not sure why that would be a good idea, because the default value of 1 (in the case of multicast) seems reasonable. I'm content to say that this method will discover a thermostat on your local network ONLY, and in any more complicated case, you should supply a FQDN or IP address. """ with ExitingSocket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) as sock: # set the receive timeout to 1 second sock.settimeout(1) # make the address reuseable sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.sendto(MESSAGE, (IP_ADDRESS, PORT)) ip_mreq = struct.pack("=4sl", socket.inet_aton(IP_ADDRESS), socket.INADDR_ANY) sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, ip_mreq) thermostats = [] while True: try: data, thermostat = sock.recvfrom(4096) # append IP address only thermostats.append(thermostat[0]) except socket.timeout: break if len(thermostats) == 0: raise IOError('No thermostats were found') if len(thermostats) > 1: raise IOError("Found %d thermostats and I don't know which to pick." % len(thermostats)) return thermostats[0] class ExitingSocket(socket.socket): """ This is a socket subclass that can be used with the "with" statement. It will attempt to clean up and close itself on exit. """ def __enter__(self): return self def __exit__(self, *args, **kwargs): try: self.setsockopt(socket.SOL_IP, socket.IP_DROP_MEMBERSHIP, socket.inet_aton(IP_ADDRESS) + socket.inet_aton('0.0.0.0')) except socket.error: pass self.close() mhrivnak-radiotherm-614a251/radiotherm/fields.py000066400000000000000000000110301400143603100216730ustar00rootroot00000000000000import json from . import validate class Field(object): """ Instances of this class act as descriptors on the Thermostat class. They define a piece of data from the API that can be accessed with GET and optionally with POST. """ def __init__(self, url, name, human_value_map=None, post_url=None, post_name=None, validate_response=validate.validate_response): """ :param url: relative URL to use for GET and POST, except when post_url is defined. :param name: key value for the data point you want. Most responses come as a dictionary, for example {'temp' : 73}, so you would pass 'temp' for this argument. :param human_value_map: An optional dictionary where keys are actual values the server might return, and values are a human-readable form. This is useful for example when a return value of "0" actually means "Off". If you pass this argument, make sure it includes all possible values. :param post_url: A few items require you to POST to a different URL than where you GET. Use this argument to specify a POST URL, and the 'url' argument will continue to be used for GETs. :param post_name: A few items that can be set are not available for reading, such as it_heat. Use this argument to specify a POST variable which is different from the GET variable. :param validate_response: The default validate_response function. Use this argument to override it. """ self.url = url self.name = name self.human_value_map = human_value_map self.post_url = post_url self.post_name = post_name self.validate_response = validate_response def __get__(self, instance, owner): response = instance.get(self.url) envelope = json.loads(response.read().decode('utf-8')) self.validate_response(response, envelope) return self._build_get_return(envelope) def _build_get_return(self, envelope): """ :param envelope: raw value returned from the thermostat, which usually is a dict. There are some attributes that don't come with an envelope, and are just the raw value by itself. In that case, set name=None. :returns: dict with key 'raw' whose value is the value this Field is setup to return. For example, it might be the current temp. If there is a human_value_map, this dict will have an additional 'human' key whose value is a human-readable version of the raw value. """ # Some URLs, like /tstat, don't have an envelope ret = {'raw' : envelope.get(self.name) if self.name else envelope} if self.human_value_map: ret['human'] = self._convert_to_human(ret['raw']) return ret def __set__(self, instance, value): data = json.dumps({self.post_name or self.name: value}).encode('utf-8') response = instance.post(self.post_url or self.url, data) self.validate_response(response) def _convert_to_human(self, value): """ :param value: raw value retrieved from the thermostat and removed from its envelope :returns: human-readable version of the value, as retrieved from self.human_value_map """ try: return self.human_value_map[value] except KeyError: raise AttributeError('Human readable value not known for raw value %s' % (str(value))) class ReadOnlyField(Field): """For read-only values like the current temperature""" def __set__(self, instance, value): raise TypeError('This attribute does not support writes.') class WriteOnlyField(Field): """For write-only values like the remote temperature""" def __get__(self, instance, owner): raise TypeError('This attribute does not support reads.') mhrivnak-radiotherm-614a251/radiotherm/thermostat.py000066400000000000000000000244601400143603100226320ustar00rootroot00000000000000import json from . import fields from . import validate try: import urllib2 as request except ImportError: from urllib import request ENABLED_HUMAN_VALUE_MAP = { 0 : 'Disabled', 1 : 'Enabled' } class Thermostat(object): """ This class implements the most basic functionality of communicating with an actual thermostat. """ MODEL = '' # The current API doesn't require this header, but it also doesn't hurt, # and it's the right thing to do. JSON_HEADER = {'Content-Type' : 'application/json'} def __init__(self, host, timeout=4): self.host = host self.timeout = timeout def get(self, relative_url): """ :param relative_url: The relative URL from the root of the website. :returns: file-like object as returned by urllib[2,.request].urlopen """ url = self._construct_url(relative_url) return request.urlopen(url, timeout=self.timeout) def post(self, relative_url, value): """ :param relative_url: The relative URL from the root of the website. :param value: Value to set this attribute to :returns: file-like object as returned by urllib[2,.request].urlopen """ url = self._construct_url(relative_url) request_instance = request.Request(url, value, self.JSON_HEADER) return request.urlopen(request_instance, timeout=self.timeout) def _construct_url(self, relative_url): """ :param relative_url: The relative URL from the root of the website :returns: Full URL, for example 'http://192.168.0.2/tstat' """ return 'http://%s/%s' % (self.host, relative_url.lstrip('/')) class CommonThermostat(Thermostat): """ This class implements the common API features that are available and work across all models of thermostat. """ def reboot(self): """reboots the thermostat""" response = self.post('/sys/command', json.dumps({'command' : 'reboot'}).encode('utf-8')) validate.validate_response(response) ### tstat subsystem ### tstat = fields.ReadOnlyField('/tstat', None, validate_response=validate.validate_tstat_response) model = fields.ReadOnlyField('/tstat/model', 'model') version = fields.Field('/tstat/version', 'version') temp = fields.ReadOnlyField('/tstat', 'temp') tmode = fields.Field('/tstat', 'tmode', human_value_map={ 0 : 'Off', 1 : 'Heat', 2 : 'Cool', 3 : 'Auto' }) fmode = fields.Field('/tstat', 'fmode', human_value_map={ 0 : 'Auto', 1 : 'Auto/Circulate', 2 : 'On' }) override = fields.ReadOnlyField('/tstat', 'override', human_value_map=ENABLED_HUMAN_VALUE_MAP) hold = fields.Field('/tstat', 'hold', human_value_map=ENABLED_HUMAN_VALUE_MAP) led = fields.Field('/tstat/led', 'energy_led') t_heat = fields.Field('/tstat/ttemp', 't_heat', post_url='/tstat') t_cool = fields.Field('/tstat/ttemp', 't_cool', post_url='/tstat') it_heat = fields.Field('/tstat/ttemp', 't_heat', post_url='/tstat', post_name='it_heat') it_cool = fields.Field('/tstat/ttemp', 't_cool', post_url='/tstat', post_name='it_cool') tstate = fields.ReadOnlyField('/tstat', 'tstate', human_value_map={ 0 : 'Off', 1 : 'Heat', 2 : 'Cool' }) fstate = fields.ReadOnlyField('/tstat', 'fstate', human_value_map={ 0 : 'Off', 1 : 'On' }) time = fields.Field('/tstat', 'time') pump = fields.ReadOnlyField('/tstat/hvac_settings', 'pump', human_value_map={ 1 : 'Normal', 2 : 'Heat Pump' }) aux_type = fields.ReadOnlyField('/tstat/hvac_settings', 'aux_type', human_value_map={ 1 : 'Gas', 2 : 'Electric' }) # LED status values: 1 = green, 2 = yellow, 4 = red energy_led = fields.WriteOnlyField('/tstat/led', 'energy_led', None) # This isn't documented. It might be postable, but I'm not going to try. power = fields.ReadOnlyField('/tstat/power', 'power') program_cool = fields.ReadOnlyField('/tstat/program/cool', None) program_heat = fields.ReadOnlyField('/tstat/program/heat', None) datalog = fields.ReadOnlyField('/tstat/datalog', None) # Remote temperature control; posting to rem_temp sets rem_mode to 1 rem_mode = fields.Field('/tstat/remote_temp', 'rem_mode', human_value_map=ENABLED_HUMAN_VALUE_MAP) rem_temp = fields.WriteOnlyField('/tstat/remote_temp', 'rem_temp', None) ### sys subsystem ### sys = fields.ReadOnlyField('/sys', None) name = fields.Field('/sys/name', 'name') services = fields.ReadOnlyField('/sys/services', None) mode = fields.Field('/sys/mode', 'mode', human_value_map={ 0 : 'Provisioning', 1: 'Normal' }) network = fields.ReadOnlyField('/sys/network', None) security = fields.ReadOnlyField('/sys/network', 'security', human_value_map = { 1 : 'WEP', 3 : 'WPA', 4 : 'WPA2 Personal' }) ### cloud subsystem ### cloud = fields.ReadOnlyField('/cloud', None) ### methods ### def set_day_program(self, heat_cool, day, program): """ Sets the program for a particular day. See the API docs for details, as it is a bit complicated. :param heat_cool: Ether the string 'heat' or 'cool' :param day: One of 'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun' :param program: See thermostat API docs :type program: dict """ self.post('/tstat/program/%s/%s' % (heat_cool, day), json.dumps(program).encode('utf-8')) class CT30(CommonThermostat): """ Base model for CT30-based thermostats (including the 3M-50) """ MODEL = 'CT30' hvac_code = fields.ReadOnlyField('/tstat/hvac_settings', 'hvac_code', human_value_map={ 1 : '1 stage heat, 1 stage cool', 2 : '2 stage heat, 1 stage cool', 3 : '2 stage heat, 2 stage cool', 4 : '2 stage heat, 1 stage cool', 5 : '2 stage heat, 2 stage cool', 10 : '1 stage pump, 1 stage aux', 11 : '1 stage pump, 1 stage aux', 12 : '1 stage pump, no aux', }) class CT80(CommonThermostat): """ Base model for CT80-based thermostats """ MODEL = 'CT80' ### Program Mode (extended tstat subsystem) program_mode = fields.Field('/tstat', 'program_mode', human_value_map={ 0 : 'Program A', 1 : 'Program B', 2 : 'Vacation', 3 : 'Holiday' }) # These three stages attributes take place of the CT30's hvac_code heat_stages = fields.ReadOnlyField('/tstat/hvac_settings', 'heat_stages') cool_stages = fields.ReadOnlyField('/tstat/hvac_settings', 'cool_stages') aux_stages = fields.ReadOnlyField('/tstat/hvac_settings', 'aux_stages') ### (De)humidifier system ### humidity = fields.ReadOnlyField('/tstat/humidity', 'humidity') humidifier_mode = fields.Field('/tstat/humidifier', 'humidifier_mode', human_value_map = { 0: 'Off', 1: 'Run only with heat', 2: 'Run any time (runs fan)', }) humidifier_setpoint = fields.Field('/tstat/thumidity', 'thumidity') class CT80RevB(CT80): """ Base model for all Revision B versions of the CT80 """ MODEL = 'CT80 RevB' swing = fields.Field('/tstat/tswing', 'tswing') # Dehumidifier attributes dehumidifier_mode = fields.Field('/tstat/dehumidifier', 'mode', human_value_map = { 0: 'Off', 1: 'On with fan', 2: 'On without fan', }) dehumidifier_setpoint = fields.Field('/tstat/dehumidifier', 'setpoint') # External dehumidifier external_dehumidifier_mode = fields.Field('/tstat/ext_dehumidifier', 'mode', human_value_map = { 0: 'Off', 1: 'On with fan', 2: 'On without fan', }) external_dehumidifier_setpoint = fields.Field('/tstat/ext_dehumidifier', 'setpoint') # Note: the night light is tricky and will return the last-set intensity even if it's off! night_light = fields.Field('/tstat/night_light', 'intensity', human_value_map = { 0: 'Off', 1: '25%', 2: '50%', 3: '75%', 4: '100%', }) # Note: lock_mode 3 can only be changed remotely lock_mode = fields.Field('/tstat/lock', 'lock_mode', human_value_map={ 0 : 'Lock disabled', 1 : 'Partial lock', 2 : 'Full lock', 3 : 'Utility lock' }) simple_mode = fields.Field('/tstat/simple_mode', 'simple_mode', human_value_map={ 1 : 'Normal mode', 2 : 'Simple mode' }) # Specific model classes class CT50(CT30): MODEL = 'CT50' class CT30v175(CT30): """ Defines API features that differ for this specific model from CommonThermostat """ MODEL = 'CT30 V1.75' class CT30v192(CT30): """ Defines API features that differ for this specific model from CommonThermostat """ MODEL = 'CT30 V1.92' class CT30v194(CT30): """ Defines API features that differ for this specific model from CommonThermostat4 """ MODEL = 'CT30 V1.94' class CT30v199(CT30): """ Defines API features that differ for this specific model from CommonThermostat """ MODEL = 'CT30 V1.99' class CT50v109(CT50): """ Defines API features that differ for this specific model from CommonThermostat """ MODEL = 'CT50 V1.09' class CT50v188(CT50): """ Defines API features that differ for this specific model from CommonThermostat """ MODEL = 'CT50 V1.88' class CT50v192(CT30): """ Defines API features that differ for this specific model from CommonThermostat """ MODEL = 'CT50 V1.92' class CT50v194(CT50): """ Defines API features that differ for this specific model from CommonThermostat """ MODEL = 'CT50 V1.94' class CT80RevB1v100(CT80RevB): MODEL = 'CT80 Rev B1 V1.00' class CT80RevB2v100(CT80RevB): MODEL = 'CT80 Rev B2 V1.00' class CT80RevB2v103(CT80RevB): MODEL = 'CT80 Rev B2 V1.03' class CT80RevB2v109(CT80RevB): MODEL = 'CT80 Rev B2 V1.09' mhrivnak-radiotherm-614a251/radiotherm/validate.py000066400000000000000000000044211400143603100222240ustar00rootroot00000000000000import json class RadiothermTstatError(Exception): pass def validate_response(response, content=None): """ raises AttributeError if the HTTP response code is not 200, or if the value returned by the server indicates an error. :param response: the response returned by a call to urllib.request.urlopen :type response: file-like object :param content: the JSON-deserialized value returned by the server. This is required if the response was "read" prior to calling this method. :type content: any iterable collection :returns: tuple of the validated response and content :raises: AttributeError """ if response.getcode() != 200: raise AttributeError('HTTP code %d. %s' % (response.code, response.msg)) if content is None: content = json.loads(response.read().decode('utf-8')) for error_field in ('error_msg', 'error'): if error_field in content: raise AttributeError('Error message from thermostat: %s' % content[error_field]) return response, content def validate_tstat_response(response, content=None): """ raises RadiothermTstatError if the HTTP response code is not 200, or if the value returned by the server for /tstat indicates an error. :param response: the response returned by a call to urllib.request.urlopen :type response: file-like object :param content: the JSON-deserialized value returned by the server. This is required if the response was "read" prior to calling this method. :type content: any iterable collection :returns: tuple of the validated response and content :raises: RadiothermTstatError """ response, content = validate_response(response, content) for field in ('temp', 'tmode', 'fmode', 'override', 'hold', 't_heat', 't_cool', 'it_heat', 'lt_cool', 'a_heat', 'a_cool', 'a_mode', 't_type_post', 'tstate', 'fstate', 'time', 'program_mode', 'ttarget'): if field not in content.keys(): continue if content[field] == -1: raise RadiothermTstatError() return response, content mhrivnak-radiotherm-614a251/setup.py000066400000000000000000000014621400143603100174370ustar00rootroot00000000000000#!/usr/bin/env python from distutils.core import setup long_desc = open('README.rst').read() setup(name='radiotherm', version='2.1.0', description='client library for wifi thermostats sold by radiothermostat.com', long_description=long_desc, packages=('radiotherm',), license='BSD', author='Michael Hrivnak', author_email='mhrivnak@hrivnak.org', url='https://github.com/mhrivnak/radiotherm', classifiers=( 'Intended Audience :: Developers', 'License :: OSI Approved :: BSD License', 'Programming Language :: Python', 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 3', 'Operating System :: OS Independent', 'Topic :: Home Automation', 'Topic :: Software Development :: Libraries', ) ) mhrivnak-radiotherm-614a251/tests/000077500000000000000000000000001400143603100170645ustar00rootroot00000000000000mhrivnak-radiotherm-614a251/tests/REQUIREMENTS.txt000066400000000000000000000000411400143603100215430ustar00rootroot00000000000000mock unittest2 # if python < 2.7 mhrivnak-radiotherm-614a251/tests/USAGE.txt000066400000000000000000000002421400143603100204670ustar00rootroot00000000000000If python >= 2.7 $ python -m unittest discover Otherwise, you must use the "unit2" script that comes with the unittest2 module $ /path/to/unit2 discover mhrivnak-radiotherm-614a251/tests/__init__.py000066400000000000000000000000001400143603100211630ustar00rootroot00000000000000mhrivnak-radiotherm-614a251/tests/base_test_case.py000066400000000000000000000004741400143603100224070ustar00rootroot00000000000000import sys try: import unittest2 as unittest except ImportError: import unittest class BaseTestCase(unittest.TestCase): @staticmethod def _get_urlopen_import_path(): if sys.version_info < (3,0): return 'urllib2.urlopen' else: return 'urllib.request.urlopen' mhrivnak-radiotherm-614a251/tests/common_thermostat/000077500000000000000000000000001400143603100226265ustar00rootroot00000000000000mhrivnak-radiotherm-614a251/tests/common_thermostat/__init__.py000066400000000000000000000000001400143603100247250ustar00rootroot00000000000000mhrivnak-radiotherm-614a251/tests/common_thermostat/test_reboot.py000066400000000000000000000020241400143603100255270ustar00rootroot00000000000000import json try: from mock import patch, MagicMock except ImportError: from unittest.mock import patch, MagicMock from radiotherm.thermostat import CommonThermostat from tests.base_test_case import BaseTestCase COMMAND = '/sys/command' IP = '192.168.0.2' JSON_VALUE = json.dumps({'command' : 'reboot'}).encode('utf-8') RESPONSE_VALUE = 'this is a response value' class TestReboot(BaseTestCase): @patch('radiotherm.thermostat.Thermostat.post') @patch('radiotherm.validate.validate_response') def test_calls_post(self, mock_validate_response, mock_post): tstat = CommonThermostat(IP) tstat.reboot() mock_post.assert_called_once_with(COMMAND, JSON_VALUE) @patch('radiotherm.thermostat.Thermostat.post', MagicMock(return_value=RESPONSE_VALUE)) @patch('radiotherm.validate.validate_response') def test_calls_validate_response(self, mock_validate_response): tstat = CommonThermostat(IP) tstat.reboot() mock_validate_response.assert_called_once_with(RESPONSE_VALUE) mhrivnak-radiotherm-614a251/tests/field/000077500000000000000000000000001400143603100201475ustar00rootroot00000000000000mhrivnak-radiotherm-614a251/tests/field/__init__.py000066400000000000000000000000001400143603100222460ustar00rootroot00000000000000mhrivnak-radiotherm-614a251/tests/field/test_build_get_return.py000066400000000000000000000022301400143603100251120ustar00rootroot00000000000000try: from mock import MagicMock except ImportError: from unittest.mock import MagicMock from tests.base_test_case import BaseTestCase from radiotherm.fields import Field class TestBuildGetReturn(BaseTestCase): INT_RETURN_VALUE = {'fake' : 1} NO_NAME_RETURN_VALUE = 72 def test_with_name(self): field = Field('/fake', 'fake') ret = field._build_get_return(self.INT_RETURN_VALUE) self.assertTrue('raw' in ret) self.assertEqual(len(ret), 1) self.assertEqual(ret['raw'], self.INT_RETURN_VALUE['fake']) def test_without_name(self): field = Field('/fake', None) ret = field._build_get_return(self.NO_NAME_RETURN_VALUE) self.assertTrue('raw' in ret) self.assertEqual(len(ret), 1) self.assertEqual(ret['raw'], self.NO_NAME_RETURN_VALUE) def test_with_human(self): field = Field('/fake', 'fake', {0: 'Off', 1: 'On'}) field._convert_to_human = MagicMock(return_value='On') ret = field._build_get_return(self.INT_RETURN_VALUE) self.assertTrue('raw' in ret) self.assertTrue('human' in ret) self.assertEqual(ret['human'], 'On') mhrivnak-radiotherm-614a251/tests/field/test_convert_to_human.py000066400000000000000000000013701400143603100251330ustar00rootroot00000000000000from radiotherm.fields import Field from tests.base_test_case import BaseTestCase class TestConvertToHuman(BaseTestCase): def setUp(self): self.field = Field('/fake', 'fake', { 0 : 'Off', 1 : 'On', 'foo' : 'Foo' }) def test_convert_int_success(self): ret = self.field._convert_to_human(0) self.assertEqual(ret, 'Off') def test_convert_int_fail(self): self.assertRaises(AttributeError, self.field._convert_to_human, 3) def test_convert_string_success(self): ret = self.field._convert_to_human('foo') self.assertEqual(ret, 'Foo') def test_convert_string_fail(self): self.assertRaises(AttributeError, self.field._convert_to_human, 'bar') mhrivnak-radiotherm-614a251/tests/field/test_read_only.py000066400000000000000000000005641400143603100235410ustar00rootroot00000000000000try: from mock import MagicMock except ImportError: from unittest.mock import MagicMock from tests.base_test_case import BaseTestCase from radiotherm.fields import ReadOnlyField class TestReadOnlyField(BaseTestCase): def test_set(self): field = ReadOnlyField('/fake', 'fake') self.assertRaises(TypeError, field.__set__, MagicMock, MagicMock) mhrivnak-radiotherm-614a251/tests/radiotherm/000077500000000000000000000000001400143603100212225ustar00rootroot00000000000000mhrivnak-radiotherm-614a251/tests/radiotherm/__init__.py000066400000000000000000000000001400143603100233210ustar00rootroot00000000000000mhrivnak-radiotherm-614a251/tests/radiotherm/test_get_thermostat.py000066400000000000000000000026341400143603100256710ustar00rootroot00000000000000try: from mock import patch, MagicMock except ImportError: from unittest.mock import patch, MagicMock import radiotherm from tests.base_test_case import BaseTestCase IP = '192.168.0.2' MODEL = 'CT50 V1.94' class TestGetThermostat(BaseTestCase): @patch('radiotherm.discover.discover_address') @patch('radiotherm.thermostat.CommonThermostat.model') def test_without_address(self, mock_model, mock_discover_address): radiotherm.get_thermostat() mock_discover_address.assert_called_once_with() @patch('radiotherm.thermostat.CommonThermostat.model') def test_creates_common_tstat(self, mock_model): with patch('radiotherm.thermostat.CommonThermostat.__init__', MagicMock(return_value=None)) as mock_init: radiotherm.get_thermostat(IP) mock_init.assert_called_once_with(IP) @patch('radiotherm.get_thermostat_class') def test_model_found(self, mock_get_class): mock_model = MagicMock() mock_model.get = lambda x: MODEL with patch('radiotherm.thermostat.CommonThermostat.model', mock_model): ret = radiotherm.get_thermostat(IP) mock_get_class.assert_called_once_with(MODEL) @patch('radiotherm.thermostat.CommonThermostat.model', MagicMock(return_value=None)) def test_model_not_found(self): ret = radiotherm.get_thermostat(IP) self.assertIsNone(ret) mhrivnak-radiotherm-614a251/tests/radiotherm/test_get_thermostat_class.py000066400000000000000000000013311400143603100270470ustar00rootroot00000000000000from tests.base_test_case import BaseTestCase import radiotherm from radiotherm.thermostat import CT50v194, CT80RevB, CT80 class TestGetThermostatClass(BaseTestCase): def test_class_exists(self): ret = radiotherm.get_thermostat_class('CT50 V1.94') self.assertEqual(ret, CT50v194) def test_class_base_rev_exists(self): ret = radiotherm.get_thermostat_class('CT80 RevB V5.94') self.assertEqual(ret, CT80RevB) def test_class_base_model_exists(self): ret = radiotherm.get_thermostat_class('CT80 RevA V2.94') self.assertEqual(ret, CT80) def test_class_does_not_exist(self): ret = radiotherm.get_thermostat_class('CT51 V3.17') self.assertIsNone(ret) mhrivnak-radiotherm-614a251/tests/thermostat/000077500000000000000000000000001400143603100212565ustar00rootroot00000000000000mhrivnak-radiotherm-614a251/tests/thermostat/__init__.py000066400000000000000000000000001400143603100233550ustar00rootroot00000000000000mhrivnak-radiotherm-614a251/tests/thermostat/test_construct_url.py000066400000000000000000000015531400143603100256010ustar00rootroot00000000000000from tests.base_test_case import BaseTestCase from radiotherm.thermostat import Thermostat class TestConstructURL(BaseTestCase): def test_with_ip(self): tstat = Thermostat('192.168.0.2') url = tstat._construct_url('/fake') self.assertEqual(url, 'http://192.168.0.2/fake') def test_with_fqdn(self): tstat = Thermostat('tstat.home.mydomain.org') url = tstat._construct_url('/fake') self.assertEqual(url, 'http://tstat.home.mydomain.org/fake') def test_without_leading_slash(self): tstat = Thermostat('192.168.0.2') url = tstat._construct_url('fake') self.assertEqual(url, 'http://192.168.0.2/fake') def test_trailing_slash_preserved(self): tstat = Thermostat('192.168.0.2') url = tstat._construct_url('/fake/') self.assertEqual(url, 'http://192.168.0.2/fake/') mhrivnak-radiotherm-614a251/tests/thermostat/test_get.py000066400000000000000000000011511400143603100234440ustar00rootroot00000000000000try: from mock import patch, MagicMock except ImportError: from unittest.mock import patch, MagicMock from tests.base_test_case import BaseTestCase from radiotherm.thermostat import Thermostat URL = 'http://192.168.0.2/fake' IP = '192.168.0.2' class TestGet(BaseTestCase): @patch('radiotherm.thermostat.Thermostat._construct_url', MagicMock(return_value=URL)) def test_opens_url(self): with patch(self._get_urlopen_import_path()) as mock_urlopen: tstat = Thermostat(IP) tstat.get('/fake') mock_urlopen.assert_called_once_with(URL, timeout=4) mhrivnak-radiotherm-614a251/tests/thermostat/test_post.py000066400000000000000000000025031400143603100236540ustar00rootroot00000000000000try: from mock import patch, MagicMock except ImportError: from unittest.mock import patch, MagicMock from tests.base_test_case import BaseTestCase from radiotherm.thermostat import Thermostat URL = 'http://192.168.0.2/fake' IP = '192.168.0.2' POST_VALUE = 'stuff' FAKE_REQUEST = 'this is a fake request' class TestPost(BaseTestCase): @patch('radiotherm.thermostat.Thermostat._construct_url', MagicMock(return_value=URL)) @patch(BaseTestCase._get_urlopen_import_path().replace('urlopen', 'Request'), MagicMock(return_value=FAKE_REQUEST)) def test_calls_urlopen(self): with patch(self._get_urlopen_import_path()) as mock_urlopen: tstat = Thermostat(IP) tstat.post('/fake', POST_VALUE) mock_urlopen.assert_called_once_with(FAKE_REQUEST, timeout=4) @patch('radiotherm.thermostat.Thermostat._construct_url', MagicMock(return_value=URL)) @patch(BaseTestCase._get_urlopen_import_path(), MagicMock(return_value=FAKE_REQUEST)) def test_creates_request(self): with patch(self._get_urlopen_import_path().replace('urlopen', 'Request')) as mock_request: tstat = Thermostat(IP) tstat.post('/fake', POST_VALUE) mock_request.assert_called_once_with(URL, POST_VALUE, Thermostat.JSON_HEADER) mhrivnak-radiotherm-614a251/tests/thermostat/test_set_day_program.py000066400000000000000000000011231400143603100260430ustar00rootroot00000000000000import json try: from mock import patch except ImportError: from unittest.mock import patch from tests.base_test_case import BaseTestCase from radiotherm.thermostat import CommonThermostat PROGRAM = {1:[480,73,1380,70,1380,70,1380,70]} class TestSetDayProgram(BaseTestCase): @patch('radiotherm.thermostat.CommonThermostat.post') def test_calls_post(self, mock_post): tstat = CommonThermostat('192.168.0.1') tstat.set_day_program('cool', 'tue', PROGRAM) mock_post.assert_called_once_with('/tstat/program/cool/tue', json.dumps(PROGRAM).encode('utf-8')) mhrivnak-radiotherm-614a251/tests/validate/000077500000000000000000000000001400143603100206555ustar00rootroot00000000000000mhrivnak-radiotherm-614a251/tests/validate/__init__.py000066400000000000000000000000001400143603100227540ustar00rootroot00000000000000mhrivnak-radiotherm-614a251/tests/validate/test_validate_response.py000066400000000000000000000040721400143603100260000ustar00rootroot00000000000000import json try: from mock import MagicMock except ImportError: from unittest.mock import MagicMock from radiotherm.validate import validate_response from tests.base_test_case import BaseTestCase class TestValidateResponse(BaseTestCase): VALIDATE_RESPONSE = staticmethod(validate_response) SIMPLE_RETURN_VALUE = {} COMPLEX_RETURN_VALUE = { 'foo' : [1, 2, 3], 'bar' : {'a' : 1, 'b' : 2} } ERROR_RETURN_VALUE = {'error' : 'stuff'} ERROR_MSG_RETURN_VALUE = {'error_msg' : 'stuff'} @staticmethod def build_mock_response(http_code, content=None): response = MagicMock() response.getcode = MagicMock(return_value=http_code) if content is not None: response.read = MagicMock(return_value=json.dumps(content).encode('utf-8')) return response def test_200(self): response = self.build_mock_response(200) self.VALIDATE_RESPONSE(response, self.SIMPLE_RETURN_VALUE) def test_404(self): response = self.build_mock_response(404) self.assertRaises(AttributeError, self.VALIDATE_RESPONSE, response, self.SIMPLE_RETURN_VALUE) def test_json_success(self): response = self.build_mock_response(200, self.COMPLEX_RETURN_VALUE) self.VALIDATE_RESPONSE(response) def test_json_error(self): response = self.build_mock_response(200, self.ERROR_RETURN_VALUE) self.assertRaises(AttributeError, self.VALIDATE_RESPONSE, response) def test_json_error_msg(self): response = self.build_mock_response(200, self.ERROR_MSG_RETURN_VALUE) self.assertRaises(AttributeError, self.VALIDATE_RESPONSE, response) def test_content_error(self): response = self.build_mock_response(200) self.assertRaises(AttributeError, self.VALIDATE_RESPONSE, response, self.ERROR_RETURN_VALUE) def test_content_error_msg(self): response = self.build_mock_response(200) self.assertRaises(AttributeError, self.VALIDATE_RESPONSE, response, self.ERROR_MSG_RETURN_VALUE) mhrivnak-radiotherm-614a251/tests/validate/test_validate_tstat_response.py000066400000000000000000000022121400143603100272110ustar00rootroot00000000000000import json try: from mock import MagicMock except ImportError: from unittest.mock import MagicMock from radiotherm.validate import validate_tstat_response, RadiothermTstatError from tests.validate.test_validate_response import TestValidateResponse class TestValidateTStatResponse(TestValidateResponse): VALIDATE_RESPONSE = staticmethod(validate_tstat_response) SIMPLE_RETURN_VALUE = {"tmode": 1, "fmode": 2, "temp": 78, "hold": 1} COMPLEX_RETURN_VALUE = SIMPLE_RETURN_VALUE TRANSIENT_ERROR_RETURN_VALUE = { "tmode": -1, "fmode": 2, "temp": -1, "hold": 1 } def test_json_transient_error(self): response = self.build_mock_response(200, self.TRANSIENT_ERROR_RETURN_VALUE) self.assertRaises(RadiothermTstatError, self.VALIDATE_RESPONSE, response) def test_content_transient_error(self): response = self.build_mock_response(200) self.assertRaises(RadiothermTstatError, self.VALIDATE_RESPONSE, response, self.TRANSIENT_ERROR_RETURN_VALUE)