pax_global_header00006660000000000000000000000064143621246030014513gustar00rootroot0000000000000052 comment=a5aa4ea34b537c730cbebbccebc66ad611001c63 broox-python-nuheat-a5aa4ea/000077500000000000000000000000001436212460300162105ustar00rootroot00000000000000broox-python-nuheat-a5aa4ea/.github/000077500000000000000000000000001436212460300175505ustar00rootroot00000000000000broox-python-nuheat-a5aa4ea/.github/workflows/000077500000000000000000000000001436212460300216055ustar00rootroot00000000000000broox-python-nuheat-a5aa4ea/.github/workflows/test.yaml000066400000000000000000000031261436212460300234520ustar00rootroot00000000000000name: Python package on: push: branches: [master] pull_request: branches: [master] jobs: test: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install flake8 pip install ".[dev]" - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --per-file-ignores='*/__init__.py:F401' --statistics - name: Unit test run: | python -m pytest --cov=./nuheat --cov-report lcov - name: Coveralls Parallel uses: coverallsapp/github-action@master with: github-token: ${{ secrets.github_token }} flag-name: run-${{ matrix.python-version }} path-to-lcov: coverage.lcov parallel: true coveralls: needs: test runs-on: ubuntu-latest steps: - name: Coveralls uses: coverallsapp/github-action@master with: github-token: ${{ secrets.GITHUB_TOKEN }} parallel-finished: true broox-python-nuheat-a5aa4ea/.gitignore000066400000000000000000000001021436212460300201710ustar00rootroot00000000000000dist/ *.egg-info/ *.pyc *.xml .cache .coverage __pycache__ .*.sw* broox-python-nuheat-a5aa4ea/Dockerfile000066400000000000000000000005311436212460300202010ustar00rootroot00000000000000FROM python:3.7-slim VOLUME /python-nuheat WORKDIR /python-nuheat COPY . /python-nuheat # pyreadline and a downgraded version of jedi are required for ipython's # autocompletion RUN pip3 install -U pip RUN pip3 install ipython pyreadline jedi==0.17.2 RUN pip3 install ".[dev]" CMD ["/bin/bash"] broox-python-nuheat-a5aa4ea/LICENSE000066400000000000000000000020331436212460300172130ustar00rootroot00000000000000Copyright 2017 Derek Brooks Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.broox-python-nuheat-a5aa4ea/README.md000066400000000000000000000074331436212460300174760ustar00rootroot00000000000000# Python NuHeat [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/nuheat?style=flat-square)](https://pypi.org/project/nuheat/) [![PyPI - Version](https://img.shields.io/pypi/v/nuheat?style=flat-square)](https://pypi.org/project/nuheat/) [![PyPI - Downloads](https://img.shields.io/pypi/dm/nuheat?style=flat-square)](https://pypi.org/project/nuheat/) [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/broox/python-nuheat/Python%20package?style=flat-square)](https://github.com/broox/python-nuheat/actions?query=branch%3Amaster) [![Coveralls](https://img.shields.io/coveralls/github/broox/python-nuheat?style=flat-square)](https://coveralls.io/github/broox/python-nuheat?branch=master) [![Snyk Vulnerabilities for GitHub Repo](https://img.shields.io/snyk/vulnerabilities/github/broox/python-nuheat?style=flat-square)](https://snyk.io/advisor/python/nuheat) A Python 3 library that allows control of connected [NuHeat Signature](http://www.nuheat.com/products/thermostats/signature-thermostat) radiant floor thermostats. * This uses the web-based NuHeat API, so it requires an external internet connection * The API in use is not an officially published API, so it could change without notice * Please contribute! # Installation ```shell $ pip install nuheat ``` # Usage ```python from nuheat import NuHeat # Initalize an API session with your login credentials api = NuHeat("email@example.com", "your-secure-password") api.authenticate() # Fetch a thermostat by serial number / ID. This can be found on the NuHeat website by selecting # your thermostat and noting the Thermostat ID thermostat = api.get_thermostat("12345") # Get the current temperature of the thermostat thermostat.fahrenheit thermostat.celsius # Get the current target temperature of the thermostat thermostat.target_fahrenheit thermostat.target_celsius # Get the minimum and maximum temperatures supported by the thermostat thermostat.min_fahrenheit thermostat.max_fahrenheit thermostat.min_celsius thermostat.max_celsius # Get the current mode of the thermostat thermostat.schedule_mode # The possible schedule modes are one of the following 3 integers: # 1. Run the schedule programmed into the thermostat # 2. Temporarily hold a target temperature until the next scheduled event # 3. Permanently hold a target temperature until the mode is manually changed # Get other properties thermostat.heating thermostat.online thermostat.serial_number # Set a new temperature and permanently hold # Note: Any pre-programmed thermostat schedules will be ignored until you resume the schedule or # change the mode. thermostat.set_target_fahrenheit(72) # If you prefer celsius... thermostat.set_target_celsius(22) # You can also do this via the convenience property setters thermostat.target_fahrenheit = 72 # or with celsius thermostat.target_celsius = 22 # To resume the schedule programmed into the thermostat thermostat.resume_schedule() # Which is effectively the same as explicitly changing the mode like so thermostat.schedule_mode = 1 # To set a new target temperature with an explicit schedule mode thermostat.set_target_fahrenheit(72, mode=2) # If you prefer celsius, you can use that too thermostat.set_target_celsius(22, mode=2) # Set a target temperature until a specified datetime # Note: A timezone aware datetime should be passed in, otherwise UTC will be assumed from datetime import datetime, timedelta, timezone hold_time = datetime.now() + timedelta(hours=4) thermostat.set_target_fahrenheit(69, mode=2, hold_time=hold_time) ``` # Contributing Pull requests are always welcome! ## Running locally with Docker ```shell # Build and run the docker container: $ docker build -t python-nuheat . $ docker run -it --rm -v $(pwd):/python-nuheat python-nuheat # To run the interactive shell: $ ipython # To run tests: $ pytest ``` broox-python-nuheat-a5aa4ea/nuheat/000077500000000000000000000000001436212460300174745ustar00rootroot00000000000000broox-python-nuheat-a5aa4ea/nuheat/__init__.py000066400000000000000000000002071436212460300216040ustar00rootroot00000000000000from nuheat.api import NuHeat from nuheat.thermostat import NuHeatThermostat import nuheat.config as config import nuheat.util as util broox-python-nuheat-a5aa4ea/nuheat/api.py000066400000000000000000000054611436212460300206250ustar00rootroot00000000000000import logging import requests import nuheat.config as config from nuheat.thermostat import NuHeatThermostat _LOGGER = logging.getLogger(__name__) _LOGGER.setLevel(logging.DEBUG) class NuHeat(object): def __init__(self, username, password, session_id=None): """ Initialize a NuHeat API session :param username: NuHeat username :param username: NuHeat password :param session_id: A Session ID token to re-use to avoid re-authenticating """ self.username = username self.password = password self._session_id = session_id def __repr__(self): return "".format(self.username) def authenticate(self): """ Authenticate against the NuHeat API """ if self._session_id: _LOGGER.debug("Using existing NuHeat session") return _LOGGER.debug("Creating NuHeat session") post_data = { "Email": self.username, "Password": self.password, "application": "0" } data = self.request(config.AUTH_URL, method="POST", data=post_data) session_id = data.get("SessionId") if not session_id: raise Exception("Authentication error") self._session_id = session_id def get_thermostat(self, serial_number): """ Get a thermostat object by serial number :param serial_number: The serial number / ID of the desired thermostat """ return NuHeatThermostat(self, serial_number) def request(self, url, method="GET", data=None, params=None, retry=True): """ Make a request to the NuHeat API :param url: The URL to request :param method: The type of request to make (GET, POST) :param data: Data to be sent along with POST requests :param params: Querystring parameters :param retry: Attempt to re-authenticate and retry request if necessary """ headers = config.REQUEST_HEADERS if params and self._session_id: params['sessionid'] = self._session_id if method == "GET": response = requests.get(url, headers=headers, params=params) elif method == "POST": response = requests.post(url, headers=headers, params=params, data=data) # Handle expired sessions if response.status_code == 401 and retry: _LOGGER.warning("NuHeat APIrequest unauthorized for [401]. Try to re-authenticate.") self._session_id = None self.authenticate() return self.request(url, method=method, data=data, params=params, retry=False) response.raise_for_status() try: return response.json() except ValueError: # No JSON object return response broox-python-nuheat-a5aa4ea/nuheat/config.py000066400000000000000000000010301436212460300213050ustar00rootroot00000000000000API_URL = "https://www.mynuheat.com/api" AUTH_URL = API_URL + "/authenticate/user" THERMOSTAT_URL = API_URL + "/thermostat" REQUEST_HEADERS = { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", "Host": "mynuheat.com", "DNT": "1", "Origin": "https://mynuheat.com/api" } # NuHeat Schedule Modes SCHEDULE_RUN = 1 SCHEDULE_TEMPORARY_HOLD = 2 # hold the target temperature until the next scheduled program SCHEDULE_HOLD = 3 # hold the target temperature until it is manually changed broox-python-nuheat-a5aa4ea/nuheat/thermostat.py000066400000000000000000000273551436212460300222540ustar00rootroot00000000000000from datetime import datetime, timezone, timedelta, time import nuheat.config as config from nuheat.util import ( celsius_to_nuheat, fahrenheit_to_nuheat, nuheat_to_celsius, nuheat_to_fahrenheit ) class NuHeatThermostat(object): _session = None _data = None _schedule_mode = None _hold_time = None heating = False online = False room = None serial_number = None temperature = None min_temperature = None max_temperature = None target_temperature = None def __init__(self, nuheat_session, serial_number): """ Initialize a local Thermostat object with the data returned from NuHeat """ self._session = nuheat_session self.serial_number = serial_number self.get_data() def __repr__(self): return "".format( self.serial_number, self.fahrenheit, self.celsius, self.target_fahrenheit, self.target_celsius ) @property def fahrenheit(self): """ Return the current temperature in Fahrenheit """ if not self.temperature: return None return nuheat_to_fahrenheit(self.temperature) @property def celsius(self): """ Return the current temperature in Celsius """ if not self.temperature: return None return nuheat_to_celsius(self.temperature) @property def min_fahrenheit(self): """ Return the thermostat's minimum temperature in Fahrenheit """ if not self.min_temperature: return None return nuheat_to_fahrenheit(self.min_temperature) @property def min_celsius(self): """ Return the thermostat's minimum temperature in Celsius """ if not self.min_temperature: return None return nuheat_to_celsius(self.min_temperature) @property def max_fahrenheit(self): """ Return the thermostat's maximum temperature in Fahrenheit """ if not self.max_temperature: return None return nuheat_to_fahrenheit(self.max_temperature) @property def max_celsius(self): """ Return the thermostat's maximum temperature in Celsius """ if not self.max_temperature: return None return nuheat_to_celsius(self.max_temperature) @property def target_fahrenheit(self): """ Return the current target temperature in Fahrenheit """ if not self.target_temperature: return None return nuheat_to_fahrenheit(self.target_temperature) @property def target_celsius(self): """ Return the current target temperature in Celsius """ if not self.target_temperature: return None return nuheat_to_celsius(self.target_temperature) @target_fahrenheit.setter def target_fahrenheit(self, fahrenheit): """ Helper to set and HOLD the target temperature to the desired fahrenheit :param fahrenheit: The desired temperature in F """ self.set_target_fahrenheit(fahrenheit) @target_celsius.setter def target_celsius(self, celsius): """ Helper to set and HOLD the target temperature to the desired fahrenheit :param celsius: The desired temperature in C """ # Note: headers are diff self.set_target_celsius(celsius) def get_data(self): """ Fetch/refresh the current instance's data from the NuHeat API """ params = { "serialnumber": self.serial_number } data = self._session.request(config.THERMOSTAT_URL, params=params) self._data = data self.heating = data.get("Heating") self.online = data.get("Online") self.room = data.get("Room") self.serial_number = data.get("SerialNumber") self.temperature = data.get("Temperature") self.min_temperature = data.get("MinTemp") self.max_temperature = data.get("MaxTemp") self.target_temperature = data.get("SetPointTemp") self._schedule_mode = data.get("ScheduleMode") hold_time_str = data.get("HoldSetPointDateTime") self._hold_time = datetime.fromisoformat(hold_time_str) @property def schedule_mode(self): """ Return the mode that the thermostat is currently using """ return self._schedule_mode @schedule_mode.setter def schedule_mode(self, mode): """ Set the thermostat mode :param mode: The desired mode integer value. Auto = 1 Temporary hold = 2 Permanent hold = 3 """ modes = [config.SCHEDULE_RUN, config.SCHEDULE_TEMPORARY_HOLD, config.SCHEDULE_HOLD] if mode not in modes: raise Exception("Invalid mode. Please use one of: {}".format(modes)) self.set_data({"ScheduleMode": mode}) @property def hold_time(self): """ Return a datetime for the current temporary hold time (or None) """ if self._schedule_mode == config.SCHEDULE_TEMPORARY_HOLD: return self._hold_time else: return None @hold_time.setter def hold_time(self, hold): """ Set a temporary hold time (and change schedule_mode to Temporary Hold). :param hold: datetime for temporary hold_time. """ hold_gmt = hold.astimezone(timezone(timedelta(0), 'GMT')) if hold_gmt < datetime.now(timezone.utc): raise Exception("Invalid hold_time - must be in the future.") hold_str = hold_gmt.strftime("%a, %d %b %Y %H:%M:%S %Z") post_data = { "ScheduleMode": config.SCHEDULE_TEMPORARY_HOLD, "HoldSetPointDateTime": hold_str } self.set_data(post_data) @property def next_schedule_event(self): """ Return dictionary containing information about the next scheduled event. """ if self._data is None: return None # Get thermostat's timezone from server data tstat_tzs = self._data.get("TZOffset", "") tstat_tz = time.fromisoformat("00:00:00" + tstat_tzs).tzinfo # Convert now to thermostat's timezone, so day/next_day will be right now = datetime.now().astimezone(tstat_tz) # Find the first active event in the next week that is after now. # Note: We start with yesterday (-1), in case yesterday's "Sleep" time # is set for today. for add_days in range(-1, 8): day = now.date() + timedelta(days=add_days) next_day = day + timedelta(days=1) dayofweek = day.weekday() for event in self._data.get("Schedules")[dayofweek].get("Events"): # Times in thermostat schedule are relative to TZOffset event_time = time.fromisoformat(event.get("Clock") + tstat_tzs) if (event.get("ScheduleType") == 3 and event_time <= time(3, 0, 0, 0, tstat_tz)): # Special case: Thermostat schedule will accept times # between midnight and 3am for the "Sleep" time. These # actually occur on the following day, based on empirical # testing. event_dt = datetime.combine(next_day, event_time) else: event_dt = datetime.combine(day, event_time) if event.get("Active") and (now < event_dt): # Convert back to local time before returning result event_dt = event_dt.astimezone(datetime.now().tzinfo) temp_floor = event.get("TempFloor") return {"Time": event_dt, "NuheatTemperature": temp_floor} # Can't find an active event. return None def resume_schedule(self): """ A convenience method to tell NuHeat to resume its programmed schedule """ self.schedule_mode = config.SCHEDULE_RUN def set_target_fahrenheit(self, fahrenheit, mode=config.SCHEDULE_HOLD, hold_time=None): """ Set the target temperature to the desired fahrenheit, with more granular control of the hold mode :param fahrenheit: The desired temperature in F :param mode: The desired mode to operate in :param hold_time: datetime object for Temporary Hold. If None, the schedule will resume at the next programmed event or previously-set hold time. """ temperature = fahrenheit_to_nuheat(fahrenheit) self.set_target_temperature(temperature, mode, hold_time) def set_target_celsius(self, celsius, mode=config.SCHEDULE_HOLD, hold_time=None): """ Set the target temperature to the desired celsius, with more granular control of the hold mode :param celsius: The desired temperature in C :param mode: The desired mode to operate in :param hold_time: datetime object for Temporary Hold. If None, the schedule will resume at the next programmed event or previously-set hold time. """ temperature = celsius_to_nuheat(celsius) self.set_target_temperature(temperature, mode, hold_time) def set_target_temperature(self, temperature, mode=config.SCHEDULE_HOLD, hold_time=None): """ Updates the target temperature on the NuHeat API :param temperature: The desired temperature in NuHeat format :param mode: The desired mode to operate in :param hold_time: datetime object for Temporary Hold. If None, the schedule will resume at the next programmed event or previously-set hold time. """ if temperature < self.min_temperature: temperature = self.min_temperature if temperature > self.max_temperature: temperature = self.max_temperature modes = [config.SCHEDULE_TEMPORARY_HOLD, config.SCHEDULE_HOLD] if mode not in modes: raise Exception("Invalid mode. Please use one of: {}".format(modes)) post_data = { "SetPointTemp": temperature, "ScheduleMode": mode } if mode == config.SCHEDULE_TEMPORARY_HOLD: if hold_time is None: # Use previously set hold_time if already in temporary hold. hold_time = self.hold_time if hold_time is None: # Hold until next scheduled event if we can determine it. event = self.next_schedule_event if event is not None: hold_time = event.get("Time") if hold_time is None: # Still don't have a hold time to use; use time from server. # Note: This doesn't always work as expected. For example, if # a temporary hold was set, then cleared, the server will give # us a HoldSetPointDateTime equal to the cleared temporary # hold time instead of the next scheduled event. hold_time = self._hold_time if hold_time is not None: hold_gmt = hold_time.astimezone(timezone(timedelta(0), 'GMT')) hold_str = hold_gmt.strftime("%a, %d %b %Y %H:%M:%S %Z") post_data["HoldSetPointDateTime"] = hold_str self.set_data(post_data) def set_data(self, post_data): """ Update (patch) the current instance's data on the NuHeat API """ params = { "serialnumber": self.serial_number } self._session.request(config.THERMOSTAT_URL, method="POST", data=post_data, params=params) broox-python-nuheat-a5aa4ea/nuheat/util.py000066400000000000000000000035341436212460300210300ustar00rootroot00000000000000from decimal import Decimal, ROUND_HALF_UP def round_half(number): """ Python's round() function behaves differently in Python 2 and 3 This method makes it consistent. :param number: The number to round """ return Decimal(number).quantize(Decimal("1"), rounding=ROUND_HALF_UP) def fahrenheit_to_celsius(fahrenheit): """ Convert Fahrenheit to Celsius :param fahrenheit: The temperature to convert to Celsius """ return int(round_half((fahrenheit - 32) / 1.8)) def fahrenheit_to_nuheat(fahrenheit): """ Convert Fahrenheit to a temperature value that NuHeat understands Formula f(x) = ((x - 33) * 56) + 33 :param fahrenheit: The temperature to convert to NuHeat """ return int(round_half(((fahrenheit - 33) * 56) + 33)) def celsius_to_fahrenheit(celsius): """ Convert Celsius to Fahrenheit :param celsius: The temperature to convert to Fahrenheit """ return int(round_half(celsius * 1.8 + 32)) def celsius_to_nuheat(celsius): """ Convert Celsius to a temperature value that NuHeat understands Formula f(x) = ((x - 33) * 56) + 33 :param celsius: The temperature to convert to NuHeat """ fahrenheit = celsius_to_fahrenheit(celsius) return int(round_half(((fahrenheit - 33) * 56) + 33)) def nuheat_to_fahrenheit(nuheat_temperature): """ Convert the NuHeat temp value to Fahrenheit Formula f(x) = ((x - 33) / 56) + 33 :param nuheat_temperature: The temperature to convert to Fahrenheit """ return int(round_half(((nuheat_temperature - 33) / 56.0) + 33)) def nuheat_to_celsius(nuheat_temperature): """ Convert the NuHeat temp value to Celsius :param nuheat_temperature: The temperature to convert to Celsius """ fahrenheit = nuheat_to_fahrenheit(nuheat_temperature) return fahrenheit_to_celsius(fahrenheit) broox-python-nuheat-a5aa4ea/requirements.txt000066400000000000000000000000211436212460300214650ustar00rootroot00000000000000requests>=2.28.1 broox-python-nuheat-a5aa4ea/setup.cfg000066400000000000000000000000471436212460300200320ustar00rootroot00000000000000[metadata] description-file = README.mdbroox-python-nuheat-a5aa4ea/setup.py000066400000000000000000000026341436212460300177270ustar00rootroot00000000000000from setuptools import setup with open('README.md', 'r') as fh: long_description = fh.read() setup( name='nuheat', packages=['nuheat'], version='1.0.1', description='A Python library that allows control of connected NuHeat Signature radiant floor thermostats.', long_description=long_description, long_description_content_type="text/markdown", author='Derek Brooks', author_email='derek@broox.com', url='https://github.com/broox/python-nuheat', download_url='https://github.com/broox/python-nuheat/archive/1.0.1.tar.gz', license='MIT', keywords=['nuheat', 'thermostat', 'home automation', 'python'], classifiers=[ 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Topic :: Software Development :: Libraries :: Python Modules', 'Topic :: Home Automation', ], install_requires=[ 'requests>=2.28.1', ], extras_require={ 'dev': [ 'coveralls==3.3.1', 'coverage==6.5.0', 'mock==4.0.3', 'pytest==7.2.0', 'pytest-cov==4.0.0', 'responses==0.22.0', ] }, python_requires=">=3.7", ) broox-python-nuheat-a5aa4ea/tests/000077500000000000000000000000001436212460300173525ustar00rootroot00000000000000broox-python-nuheat-a5aa4ea/tests/__init__.py000066400000000000000000000021601436212460300214620ustar00rootroot00000000000000import json import os import unittest from urllib.parse import parse_qsl, urlparse def load_fixture(filename): """ Load some fixture JSON """ path = os.path.join(os.path.dirname(__file__), "fixtures", filename) with open(path) as json_data: return json.load(json_data) class Url(object): """ A url object that can be compared with other url orbjects without regard to the vagaries of encoding, escaping, and ordering of parameters in query strings. """ _url = None def __init__(self, url): self._url = url parts = urlparse(url) _query = frozenset(parse_qsl(parts.query)) _path = parts.path parts = parts._replace(query=_query, path=_path) self.parts = parts def __repr__(self): return "".format(self._url) def __eq__(self, other): return self.parts == other.parts def __hash__(self): return hash(self.parts) class NuTestCase(unittest.TestCase): def assertUrlsEqual(self, url1, url2): # pylint: disable=invalid-name self.assertEqual(Url(url1), Url(url2)) broox-python-nuheat-a5aa4ea/tests/fixtures/000077500000000000000000000000001436212460300212235ustar00rootroot00000000000000broox-python-nuheat-a5aa4ea/tests/fixtures/auth_error.json000066400000000000000000000001761436212460300242740ustar00rootroot00000000000000{ "SessionId": "", "NewAccount": false, "ErrorCode": 2, "RoleType": 0, "Email": "", "Language": "EN" }broox-python-nuheat-a5aa4ea/tests/fixtures/auth_success.json000066400000000000000000000002401436212460300246030ustar00rootroot00000000000000{ "SessionId": "test-session-id", "NewAccount": false, "ErrorCode": 0, "RoleType": 3000, "Email": "test@example.com", "Language": "EN" }broox-python-nuheat-a5aa4ea/tests/fixtures/thermostat.json000066400000000000000000000132051436212460300243110ustar00rootroot00000000000000 { "SerialNumber":"12345", "Room":"master bath", "GroupName":"Glenwood", "GroupId":1234, "GroupAwayMode":false, "Temperature":2643, "SetPointTemp":2222, "ScheduleMode":1, "OperatingMode":1, "HoldSetPointDateTime":"2017-11-04T02:30:00+00:00", "Online":true, "Heating":false, "MaxTemp":7000, "MinTemp":500, "ErrorCode":0, "Confirmed":true, "Email":"test@example.com", "TZOffset":"-06:00", "Assigned":true, "FloorArea":0.0, "KwCharge":0.0, "WPerSquareUnit":12.0, "SWVersion":"108", "HasBeenAssigned":true, "Schedules":[ { "WeekDayGrpNo":1, "Events":[ { "ScheduleType":0, "Clock":"05:45:00", "TempFloor":2666, "Active":true }, { "ScheduleType":1, "Clock":"10:00:00", "TempFloor":2222, "Active":true }, { "ScheduleType":2, "Clock":"21:30:00", "TempFloor":2666, "Active":true }, { "ScheduleType":3, "Clock":"00:00:00", "TempFloor":2222, "Active":true } ] }, { "WeekDayGrpNo":1, "Events":[ { "ScheduleType":0, "Clock":"05:45:00", "TempFloor":2666, "Active":true }, { "ScheduleType":1, "Clock":"10:00:00", "TempFloor":2222, "Active":true }, { "ScheduleType":2, "Clock":"21:30:00", "TempFloor":2666, "Active":true }, { "ScheduleType":3, "Clock":"00:00:00", "TempFloor":2222, "Active":true } ] }, { "WeekDayGrpNo":1, "Events":[ { "ScheduleType":0, "Clock":"05:45:00", "TempFloor":2666, "Active":true }, { "ScheduleType":1, "Clock":"10:00:00", "TempFloor":2222, "Active":true }, { "ScheduleType":2, "Clock":"21:30:00", "TempFloor":2666, "Active":true }, { "ScheduleType":3, "Clock":"00:00:00", "TempFloor":2222, "Active":true } ] }, { "WeekDayGrpNo":1, "Events":[ { "ScheduleType":0, "Clock":"05:45:00", "TempFloor":2666, "Active":true }, { "ScheduleType":1, "Clock":"10:00:00", "TempFloor":2222, "Active":true }, { "ScheduleType":2, "Clock":"21:30:00", "TempFloor":2666, "Active":true }, { "ScheduleType":3, "Clock":"00:00:00", "TempFloor":2222, "Active":true } ] }, { "WeekDayGrpNo":1, "Events":[ { "ScheduleType":0, "Clock":"05:45:00", "TempFloor":2666, "Active":true }, { "ScheduleType":1, "Clock":"10:00:00", "TempFloor":2222, "Active":true }, { "ScheduleType":2, "Clock":"21:30:00", "TempFloor":2666, "Active":true }, { "ScheduleType":3, "Clock":"00:00:00", "TempFloor":2222, "Active":true } ] }, { "WeekDayGrpNo":2, "Events":[ { "ScheduleType":0, "Clock":"08:00:00", "TempFloor":2666, "Active":true }, { "ScheduleType":1, "Clock":"09:00:00", "TempFloor":2333, "Active":true }, { "ScheduleType":2, "Clock":"22:00:00", "TempFloor":2666, "Active":true }, { "ScheduleType":3, "Clock":"23:00:00", "TempFloor":2333, "Active":true } ] }, { "WeekDayGrpNo":2, "Events":[ { "ScheduleType":0, "Clock":"08:00:00", "TempFloor":2666, "Active":true }, { "ScheduleType":1, "Clock":"09:00:00", "TempFloor":2333, "Active":true }, { "ScheduleType":2, "Clock":"22:00:00", "TempFloor":2666, "Active":true }, { "ScheduleType":3, "Clock":"23:00:00", "TempFloor":2333, "Active":true } ] } ], "EnergyOverview":{ "Hours":52 } }broox-python-nuheat-a5aa4ea/tests/test_nuheat.py000066400000000000000000000072311436212460300222520ustar00rootroot00000000000000import json import responses from mock import patch from urllib.parse import urlencode from nuheat import NuHeat, NuHeatThermostat, config from . import NuTestCase, load_fixture class TestNuHeat(NuTestCase): # pylint: disable=protected-access def test_init_with_session(self): existing_session_id = "passed-session" api = NuHeat("test@example.com", "secure-password", existing_session_id) self.assertEqual(api._session_id, existing_session_id) api.authenticate() def test_repr(self): email = "test@example.com" api = NuHeat(email, "secure-password") self.assertEqual(str(api), "".format(email)) @responses.activate def test_successful_authentication(self): response_data = load_fixture("auth_success.json") responses.add( responses.POST, config.AUTH_URL, status=200, body=json.dumps(response_data), content_type="application/json" ) api = NuHeat("test@example.com", "secure-password") self.assertIsNone(api._session_id) api.authenticate() self.assertEqual(api._session_id, response_data.get("SessionId")) @responses.activate def test_authentication_error(self): response_data = load_fixture("auth_error.json") responses.add( responses.POST, config.AUTH_URL, status=200, body=json.dumps(response_data), content_type="application/json" ) api = NuHeat("test@example.com", "secure-password") with self.assertRaises(Exception) as _: api.authenticate() self.assertIsNone(api._session_id) def test_authentication_failure(self): # TODO: 401, expired session pass @patch("nuheat.NuHeatThermostat.get_data") def test_get_thermostat(self, _): api = NuHeat(None, None) serial_number = "serial-123" thermostat = api.get_thermostat(serial_number) self.assertTrue(isinstance(thermostat, NuHeatThermostat)) @responses.activate def test_get_request(self): url = "http://www.example.com/api" params = dict(test="param") responses.add( responses.GET, url, status=200, content_type="application/json" ) api = NuHeat(None, None) response = api.request(url, method="GET", params=params) self.assertEqual(response.status_code, 200) self.assertUrlsEqual(response.request.url, "{}?{}".format(url, urlencode(params))) request_headers = response.request.headers self.assertEqual(request_headers["Origin"], config.REQUEST_HEADERS["Origin"]) self.assertEqual(request_headers["Content-Type"], config.REQUEST_HEADERS["Content-Type"]) @responses.activate def test_post_request(self): url = "http://www.example.com/api" params = dict(test="param") data = dict(test="data") responses.add( responses.POST, url, status=200, content_type="application/json" ) api = NuHeat(None, None) response = api.request(url, method="POST", data=data, params=params) self.assertEqual(response.status_code, 200) self.assertUrlsEqual(response.request.url, "{}?{}".format(url, urlencode(params))) self.assertEqual(response.request.body, urlencode(data)) request_headers = response.request.headers self.assertEqual(request_headers["Origin"], config.REQUEST_HEADERS["Origin"]) self.assertEqual(request_headers["Content-Type"], config.REQUEST_HEADERS["Content-Type"]) broox-python-nuheat-a5aa4ea/tests/test_thermostat.py000066400000000000000000000515441436212460300231660ustar00rootroot00000000000000import json import responses from datetime import datetime, timezone, timedelta from mock import patch from urllib.parse import urlencode from nuheat import NuHeat, NuHeatThermostat, config from . import NuTestCase, load_fixture class TestThermostat(NuTestCase): # pylint: disable=protected-access # pylint: disable=no-self-use @patch("nuheat.NuHeatThermostat.get_data") def test_init(self, _): api = NuHeat(None, None) serial_number = "serial-123" thermostat = NuHeatThermostat(api, serial_number) self.assertEqual(thermostat.serial_number, serial_number) self.assertEqual(thermostat._session, api) @patch("nuheat.NuHeatThermostat.get_data") def test_repr_without_data(self, _): api = NuHeat(None, None) serial_number = "serial-123" thermostat = NuHeatThermostat(api, serial_number) self.assertEqual( str(thermostat), "".format( serial_number, None, None, None, None ) ) @patch("nuheat.NuHeatThermostat.get_data") def test_repr_with_data(self, _): api = NuHeat(None, None) serial_number = "serial-123" thermostat = NuHeatThermostat(api, serial_number) thermostat.temperature = 2000 thermostat.target_temperature = 5000 self.assertEqual( str(thermostat), "".format( serial_number, 68, 20, 122, 50 ) ) @patch("nuheat.NuHeatThermostat.get_data") def test_fahrenheit(self, _): thermostat = NuHeatThermostat(None, None) thermostat.temperature = 2222 self.assertEqual(thermostat.fahrenheit, 72) @patch("nuheat.NuHeatThermostat.get_data") def test_celsius(self, _): thermostat = NuHeatThermostat(None, None) thermostat.temperature = 2222 self.assertEqual(thermostat.celsius, 22) @patch("nuheat.NuHeatThermostat.get_data") def test_min_fahrenheit(self, _): thermostat = NuHeatThermostat(None, None) self.assertEqual(thermostat.min_fahrenheit, None) thermostat.min_temperature = 500 self.assertEqual(thermostat.min_fahrenheit, 41) @patch("nuheat.NuHeatThermostat.get_data") def test_min_celsius(self, _): thermostat = NuHeatThermostat(None, None) self.assertEqual(thermostat.min_celsius, None) thermostat.min_temperature = 500 self.assertEqual(thermostat.min_celsius, 5) @patch("nuheat.NuHeatThermostat.get_data") def test_max_fahrenheit(self, _): thermostat = NuHeatThermostat(None, None) self.assertEqual(thermostat.max_fahrenheit, None) thermostat.max_temperature = 7000 self.assertEqual(thermostat.max_fahrenheit, 157) @patch("nuheat.NuHeatThermostat.get_data") def test_max_celsius(self, _): thermostat = NuHeatThermostat(None, None) self.assertEqual(thermostat.max_celsius, None) thermostat.max_temperature = 7000 self.assertEqual(thermostat.max_celsius, 69) @patch("nuheat.NuHeatThermostat.get_data") def test_target_fahrenheit(self, _): thermostat = NuHeatThermostat(None, None) thermostat.target_temperature = 2222 self.assertEqual(thermostat.target_fahrenheit, 72) @patch("nuheat.NuHeatThermostat.get_data") def test_target_celsius(self, _): thermostat = NuHeatThermostat(None, None) thermostat.target_temperature = 2222 self.assertEqual(thermostat.target_celsius, 22) @patch("nuheat.NuHeatThermostat.get_data") @patch("nuheat.NuHeatThermostat.set_target_fahrenheit") def test_target_fahrenheit_setter(self, set_target_fahrenheit, _): thermostat = NuHeatThermostat(None, None) thermostat.target_fahrenheit = 80 set_target_fahrenheit.assert_called_with(80) @patch("nuheat.NuHeatThermostat.get_data") @patch("nuheat.NuHeatThermostat.set_target_celsius") def test_target_celsius_setter(self, set_target_celsius, _): thermostat = NuHeatThermostat(None, None) thermostat.target_celsius = 26 set_target_celsius.assert_called_with(26) @responses.activate def test_get_data(self): response_data = load_fixture("thermostat.json") responses.add( responses.GET, config.THERMOSTAT_URL, status=200, body=json.dumps(response_data), content_type="application/json" ) api = NuHeat(None, None, session_id="my-session") serial_number = response_data.get("SerialNumber") params = { "sessionid": api._session_id, "serialnumber": serial_number } request_url = "{}?{}".format(config.THERMOSTAT_URL, urlencode(params)) thermostat = NuHeatThermostat(api, serial_number) thermostat.get_data() api_calls = responses.calls # Data is fetched once on instantiation and once on get_data() self.assertEqual(len(api_calls), 2) api_call = api_calls[0] self.assertEqual(api_call.request.method, "GET") self.assertUrlsEqual(api_call.request.url, request_url) self.assertEqual(thermostat._data, response_data) self.assertEqual(thermostat.heating, response_data["Heating"]) self.assertEqual(thermostat.online, response_data["Online"]) self.assertEqual(thermostat.room, response_data["Room"]) self.assertEqual(thermostat.serial_number, response_data["SerialNumber"]) self.assertEqual(thermostat.temperature, response_data["Temperature"]) self.assertEqual(thermostat.min_temperature, response_data["MinTemp"]) self.assertEqual(thermostat.max_temperature, response_data["MaxTemp"]) self.assertEqual(thermostat.target_temperature, response_data["SetPointTemp"]) self.assertEqual(thermostat.schedule_mode, response_data["ScheduleMode"]) @responses.activate def test_get_data_401(self): # First request (when initializing the thermostat) is successful response_data = load_fixture("thermostat.json") responses.add( responses.GET, config.THERMOSTAT_URL, status=200, body=json.dumps(response_data), content_type="application/json" ) # A later, second request throws 401 Unauthorized responses.add( responses.GET, config.THERMOSTAT_URL, status=401 ) # Attempt to reauthenticate auth_data = load_fixture("auth_success.json") responses.add( responses.POST, config.AUTH_URL, status=200, body=json.dumps(auth_data), content_type="application/json" ) # Third request is successful responses.add( responses.GET, config.THERMOSTAT_URL, status=200, body=json.dumps(response_data), content_type="application/json" ) bad_session_id = "my-bad-session" good_session_id = auth_data.get("SessionId") api = NuHeat(None, None, session_id=bad_session_id) serial_number = response_data.get("SerialNumber") thermostat = NuHeatThermostat(api, serial_number) thermostat.get_data() self.assertTrue(isinstance(thermostat, NuHeatThermostat)) api_calls = responses.calls self.assertEqual(len(api_calls), 4) unauthorized_attempt = api_calls[1] params = {"sessionid": bad_session_id, "serialnumber": serial_number} request_url = "{}?{}".format(config.THERMOSTAT_URL, urlencode(params)) self.assertEqual(unauthorized_attempt.request.method, "GET") self.assertUrlsEqual(unauthorized_attempt.request.url, request_url) self.assertEqual(unauthorized_attempt.response.status_code, 401) auth_call = api_calls[2] self.assertEqual(auth_call.request.method, "POST") self.assertUrlsEqual(auth_call.request.url, config.AUTH_URL) second_attempt = api_calls[3] params["sessionid"] = good_session_id request_url = "{}?{}".format(config.THERMOSTAT_URL, urlencode(params)) self.assertEqual(second_attempt.request.method, "GET") self.assertUrlsEqual(second_attempt.request.url, request_url) self.assertEqual(second_attempt.response.status_code, 200) @patch("nuheat.NuHeatThermostat.get_data") def test_schedule_mode(self, _): thermostat = NuHeatThermostat(None, None) thermostat._schedule_mode = 1 self.assertEqual(thermostat.schedule_mode, 1) @patch("nuheat.NuHeatThermostat.get_data") @patch("nuheat.NuHeatThermostat.set_data") def test_schedule_mode_setter(self, set_data, _): thermostat = NuHeatThermostat(None, None) thermostat.schedule_mode = 2 set_data.assert_called_with({"ScheduleMode": 2}) # Invalid mode with self.assertRaises(Exception) as _: thermostat.schedule_mode = 5 @patch("nuheat.NuHeatThermostat.get_data") @patch("nuheat.NuHeatThermostat.set_data") def test_resume_schedule(self, set_data, _): thermostat = NuHeatThermostat(None, None) thermostat.resume_schedule() set_data.assert_called_with({"ScheduleMode": config.SCHEDULE_RUN}) @patch("nuheat.NuHeatThermostat.get_data") @patch("nuheat.NuHeatThermostat.set_target_temperature") def test_set_target_fahrenheit(self, set_target_temperature, _): thermostat = NuHeatThermostat(None, None) thermostat.set_target_fahrenheit(80) set_target_temperature.assert_called_with(2665, config.SCHEDULE_HOLD, None) thermostat = NuHeatThermostat(None, None) thermostat.set_target_fahrenheit(80, config.SCHEDULE_TEMPORARY_HOLD) set_target_temperature.assert_called_with(2665, config.SCHEDULE_TEMPORARY_HOLD, None) @patch("nuheat.NuHeatThermostat.get_data") @patch("nuheat.NuHeatThermostat.set_target_temperature") def test_set_target_celsius(self, set_target_temperature, _): thermostat = NuHeatThermostat(None, None) thermostat.set_target_celsius(26) set_target_temperature.assert_called_with(2609, config.SCHEDULE_HOLD, None) thermostat = NuHeatThermostat(None, None) thermostat.set_target_celsius(26, config.SCHEDULE_TEMPORARY_HOLD) set_target_temperature.assert_called_with(2609, config.SCHEDULE_TEMPORARY_HOLD, None) @patch("nuheat.NuHeatThermostat.get_data") @patch("nuheat.NuHeatThermostat.set_data") def test_set_target_temperature(self, set_data, _): thermostat = NuHeatThermostat(None, None) thermostat.min_temperature = 500 thermostat.max_temperature = 7000 # Permanent hold thermostat.set_target_temperature(2222) set_data.assert_called_with({ "SetPointTemp": 2222, "ScheduleMode": config.SCHEDULE_HOLD }) self.assertEqual(thermostat.hold_time, None) # Temporary hold - no schedule, no hold time from server thermostat.set_target_temperature(2222, mode=config.SCHEDULE_TEMPORARY_HOLD) set_data.assert_called_with({ "SetPointTemp": 2222, "ScheduleMode": config.SCHEDULE_TEMPORARY_HOLD }) # Temporary hold - no schedule, server HoldSetPointDateTime available # Battle of Lexington and Concord est = timezone(timedelta(hours=-5), 'EST') thermostat.schedule_mode = config.SCHEDULE_RUN thermostat._hold_time = datetime(1775, 4, 19, 5, 0, tzinfo=est) thermostat.set_target_temperature(2223, mode=config.SCHEDULE_TEMPORARY_HOLD) set_data.assert_called_with({ "SetPointTemp": 2223, "ScheduleMode": config.SCHEDULE_TEMPORARY_HOLD, "HoldSetPointDateTime": "Wed, 19 Apr 1775 10:00:00 GMT" }) # Temporary hold - time specified # Attack on Fort Sumter hold_time = datetime(1861, 4, 12, 4, 30, tzinfo=est) thermostat.set_target_temperature(2224, mode=config.SCHEDULE_TEMPORARY_HOLD, hold_time=hold_time) set_data.assert_called_with({ "SetPointTemp": 2224, "ScheduleMode": config.SCHEDULE_TEMPORARY_HOLD, "HoldSetPointDateTime": "Fri, 12 Apr 1861 09:30:00 GMT" }) # Simulate effect of calling get_data after setting temporary hold thermostat._schedule_mode = config.SCHEDULE_TEMPORARY_HOLD thermostat._hold_time = datetime.fromisoformat("1861-04-12T09:30:00+00:00") self.assertEqual(thermostat.hold_time, thermostat._hold_time) # Temporary hold - use previous hold time # Attack on Fort Sumter thermostat.set_target_temperature(2225, mode=config.SCHEDULE_TEMPORARY_HOLD) set_data.assert_called_with({ "SetPointTemp": 2225, "ScheduleMode": config.SCHEDULE_TEMPORARY_HOLD, "HoldSetPointDateTime": "Fri, 12 Apr 1861 09:30:00 GMT" }) # Below minimum thermostat.set_target_temperature(481) set_data.assert_called_with({ "SetPointTemp": 500, "ScheduleMode": config.SCHEDULE_HOLD }) # Above maximum thermostat.set_target_temperature(7020) set_data.assert_called_with({ "SetPointTemp": 7000, "ScheduleMode": config.SCHEDULE_HOLD }) # Invalid mode with self.assertRaises(Exception) as _: thermostat.set_target_temperature(2222, 5) @patch("nuheat.NuHeatThermostat.get_data") @patch("nuheat.NuHeatThermostat.set_data") def test_hold_time_setter(self, set_data, _): thermostat = NuHeatThermostat(None, None) thermostat.min_temperature = 500 thermostat.max_temperature = 7000 # Battle of Gettysburg est = timezone(timedelta(hours=-5), 'EST') hold_time = datetime(1863, 7, 1, 7, 30, tzinfo=est) with self.assertRaises(Exception) as _: # Invalid hold_time - must be in the future. thermostat.hold_time = hold_time # 6 hours from now hold_time = datetime.now(timezone.utc) + timedelta(hours=+6) hold_str = hold_time.strftime("%a, %d %b %Y %H:%M:%S GMT") thermostat.hold_time = hold_time set_data.assert_called_with({ "ScheduleMode": config.SCHEDULE_TEMPORARY_HOLD, "HoldSetPointDateTime": hold_str }) @responses.activate def test_next_schedule_event(self): # Use thermostat.json to load a schedule into thermostat response_data = load_fixture("thermostat.json") responses.add( responses.GET, config.THERMOSTAT_URL, status=200, body=json.dumps(response_data), content_type="application/json" ) api = NuHeat(None, None, session_id="my-session") serial_number = response_data.get("SerialNumber") thermostat = NuHeatThermostat(api, serial_number) thermostat.get_data() # Does anybody really know what time it is? with patch("nuheat.thermostat.datetime", wraps=datetime) as mock_dt: # Monday @ 11:00am (WW1 Armistice) wet = timezone(timedelta(hours=+1), 'WET') thermostat._data["TZOffset"] = "+01:00" mock_dt.now.return_value = datetime(1918, 11, 11, 11, 0, tzinfo=wet) next_event = thermostat.next_schedule_event # next_event = 9:30pm, same day self.assertEqual(next_event.get("Time"), datetime(1918, 11, 11, 21, 30, tzinfo=wet)) self.assertEqual(next_event.get("NuheatTemperature"), 2666) # Monday @ 02:41am (VE Day, first signing) wemt = timezone(timedelta(hours=+2), 'WEMT') thermostat._data["TZOffset"] = "+02:00" mock_dt.now.return_value = datetime(1945, 5, 7, 2, 41, tzinfo=wemt) # next_event = Monday @ 5:45am next_event = thermostat.next_schedule_event self.assertEqual(next_event.get("Time"), datetime(1945, 5, 7, 5, 45, tzinfo=wemt)) self.assertEqual(next_event.get("NuheatTemperature"), 2666) # Change Sunday "sleep" time to 3am thermostat._data["Schedules"][6]["Events"][3]["Clock"] = "03:00:00" # next_event = Monday @ 3:00am next_event = thermostat.next_schedule_event self.assertEqual(next_event.get("Time"), datetime(1945, 5, 7, 3, 0, tzinfo=wemt)) self.assertEqual(next_event.get("NuheatTemperature"), 2333) # Friday @ 10:45pm (Fall of Berlin Wall) cet = timezone(timedelta(hours=+1), 'CET') thermostat._data["TZOffset"] = "+01:00" mock_dt.now.return_value = datetime(1990, 11, 9, 22, 45, tzinfo=cet) # next_event = Midnight Friday night / Saturday morning next_event = thermostat.next_schedule_event self.assertEqual(next_event.get("Time"), datetime(1990, 11, 10, 0, 0, tzinfo=cet)) self.assertEqual(next_event.get("NuheatTemperature"), 2222) # Disable Friday's "sleep" event thermostat._data["Schedules"][4]["Events"][3]["Active"] = False # next_event = 8am Saturday morning next_event = thermostat.next_schedule_event self.assertEqual(next_event.get("Time"), datetime(1990, 11, 10, 8, 0, tzinfo=cet)) self.assertEqual(next_event.get("NuheatTemperature"), 2666) # Simulate thermostat and python-nuheat in different timezones # Thermostat in GMT (schedule becomes relative to there) thermostat._data["TZOffset"] = "+00:00" # next_event = 9am Saturday morning next_event = thermostat.next_schedule_event next_event["Time"] = next_event["Time"].astimezone(cet) self.assertEqual(next_event.get("Time"), datetime(1990, 11, 10, 9, 0, tzinfo=cet)) self.assertEqual(next_event.get("NuheatTemperature"), 2666) # Thermostat in Auckland, NZ (where time is 10:45am Saturday) thermostat._data["TZOffset"] = "+13:00" nzdt = timezone(timedelta(hours=+13), 'NZDT') # next_event = 10pm Saturday evening in Auckland next_event = thermostat.next_schedule_event next_event["Time"] = next_event["Time"].astimezone(nzdt) self.assertEqual(next_event.get("Time"), datetime(1990, 11, 10, 22, 0, tzinfo=nzdt)) self.assertEqual(next_event.get("NuheatTemperature"), 2666) @responses.activate @patch("nuheat.NuHeatThermostat.set_data") def test_set_target_temperature_temporary_hold_time(self, set_data): response_data = load_fixture("thermostat.json") responses.add( responses.GET, config.THERMOSTAT_URL, status=200, body=json.dumps(response_data), content_type="application/json" ) api = NuHeat(None, None, session_id="my-session") serial_number = response_data.get("SerialNumber") with patch("nuheat.thermostat.datetime", wraps=datetime) as mock_dt: thermostat = NuHeatThermostat(api, serial_number) thermostat.get_data() cet = timezone(timedelta(hours=+1), 'CET') thermostat._data["TZOffset"] = "+01:00" mock_dt.now.return_value = datetime(1990, 11, 9, 22, 45, tzinfo=cet) thermostat.set_target_temperature(2222, config.SCHEDULE_TEMPORARY_HOLD) set_data.assert_called_with({ "SetPointTemp": 2222, "ScheduleMode": config.SCHEDULE_TEMPORARY_HOLD, "HoldSetPointDateTime": "Fri, 09 Nov 1990 23:00:00 GMT" }) @responses.activate @patch("nuheat.NuHeatThermostat.get_data") def test_set_data(self, _): responses.add( responses.POST, config.THERMOSTAT_URL, status=200, content_type="application/json" ) api = NuHeat(None, None, session_id="my-session") serial_number = "my-thermostat" params = { "sessionid": api._session_id, "serialnumber": serial_number } request_url = "{}?{}".format(config.THERMOSTAT_URL, urlencode(params)) post_data = {"test": "data"} thermostat = NuHeatThermostat(api, serial_number) thermostat.set_data(post_data) api_call = responses.calls[0] self.assertEqual(api_call.request.method, "POST") self.assertUrlsEqual(api_call.request.url, request_url) self.assertEqual(api_call.request.body, urlencode(post_data)) broox-python-nuheat-a5aa4ea/tests/test_utils.py000066400000000000000000000037751436212460300221370ustar00rootroot00000000000000import unittest import nuheat.util as util class TestUtils(unittest.TestCase): def test_round_half(self): tests = [ [0.0, 0], [31.4, 31], [31.5, 32], [32.4, 32], [32.5, 33] ] for test in tests: rounded = util.round_half(test[0]) self.assertEqual(rounded, test[1]) def test_fahrenheit_to_celsius(self): tests = [ [32, 0], [72, 22], [212, 100] ] for test in tests: celsius = util.fahrenheit_to_celsius(test[0]) self.assertEqual(celsius, test[1]) def test_celsius_to_fahrenheit(self): tests = [ [32, 0], [72, 22], [212, 100] ] for test in tests: fahrenheit = util.celsius_to_fahrenheit(test[1]) self.assertEqual(fahrenheit, test[0]) def test_fahrenheit_to_nuheat(self): tests = [ [41, 481], # min [72, 2217], [157, 6977] # max ] for test in tests: temp = util.fahrenheit_to_nuheat(test[0]) self.assertEqual(temp, test[1]) def test_celsius_to_nuheat(self): tests = [ [5, 481], # min [22, 2217], [69, 6921] # max ] for test in tests: temp = util.celsius_to_nuheat(test[0]) self.assertEqual(temp, test[1]) def test_nuheat_to_fahrenheit(self): tests = [ [500, 41], # min [2222, 72], [7000, 157] # max ] for test in tests: fahrenheit = util.nuheat_to_fahrenheit(test[0]) self.assertEqual(fahrenheit, test[1]) def test_nuheat_to_celsius(self): tests = [ [500, 5], # min [2222, 22], [7000, 69] # max ] for test in tests: celsius = util.nuheat_to_celsius(test[0]) self.assertEqual(celsius, test[1])