From 4291ae2076fbef2140475a4e6bc54133030c72fc Mon Sep 17 00:00:00 2001 From: Petr Svoboda Date: Sun, 17 Nov 2024 10:54:06 +0100 Subject: [PATCH 1/4] python style changes --- PyTado/__main__.py | 8 ++--- PyTado/interface.py | 76 ++++++++++++++++++++++----------------------- 2 files changed, 42 insertions(+), 42 deletions(-) diff --git a/PyTado/__main__.py b/PyTado/__main__.py index 5591d66..cf7f18a 100644 --- a/PyTado/__main__.py +++ b/PyTado/__main__.py @@ -16,17 +16,17 @@ def log_in(email, password): def get_me(args): t = log_in(args.email, args.password) - me = tado_client.getMe(t) + me = tado_client.get_me(t) print(me) def get_state(args): t = log_in(args.email, args.password) - zone = tado_client.getState(t,int(args.zone)) + zone = tado_client.get_state(t, int(args.zone)) print(zone) def get_capabilities(args): t = log_in(args.email, args.password) - capabilities = tado_client.getCapabilities(t,int(args.zone)) + capabilities = tado_client.get_capabilities(t, int(args.zone)) print(capabilities) def main(): @@ -39,7 +39,7 @@ def main(): # Required flags go here. required_flags.add_argument('--email', required=True, - help=('Tado username in the form of an email address.')) + help='Tado username in the form of an email address.') required_flags.add_argument('--password', required=True, help='Tado password.') diff --git a/PyTado/interface.py b/PyTado/interface.py index 33b7d69..32df437 100644 --- a/PyTado/interface.py +++ b/PyTado/interface.py @@ -31,7 +31,7 @@ class Tado: refresh_at = datetime.datetime.now() + datetime.timedelta(minutes=5) # 'Private' methods for use in class, Tado mobile API V1.9. - def _mobile_apiCall(self, cmd): + def _mobile_api_call(self, cmd): # pylint: disable=C0103 self._refresh_token() @@ -53,7 +53,7 @@ def _mobile_apiCall(self, cmd): return data # 'Private' methods for use in class, Tado API V2. - def _apiCall(self, cmd, method="GET", data=None, plain=False): + def _api_call(self, cmd, method="GET", data=None, plain=False): # pylint: disable=C0103 self._refresh_token() @@ -91,7 +91,7 @@ def _apiCall(self, cmd, method="GET", data=None, plain=False): data = json.loads(str_response) return data - def _setOAuthHeader(self, data): + def _set_o_auth_header(self, data): # pylint: disable=C0103 access_token = data['access_token'] @@ -128,10 +128,10 @@ def _refresh_token(self): response = self.opener.open(req) str_response = response.read().decode('utf-8') - self._setOAuthHeader(json.loads(str_response)) + self._set_o_auth_header(json.loads(str_response)) return response - def _loginV2(self, username, password): + def _login_v2(self, username, password): # pylint: disable=C0103 headers = self.headers @@ -154,15 +154,15 @@ def _loginV2(self, username, password): response = self.opener.open(req) str_response = response.read().decode('utf-8') - self._setOAuthHeader(json.loads(str_response)) + self._set_o_auth_header(json.loads(str_response)) return response - def setDebugging(self, debugCalls): - self._debugCalls = debugCalls + def set_debugging(self, debug_calls): + self._debugCalls = debug_calls return self._debugCalls # Public interface - def getMe(self): + def get_me(self): """Gets home information.""" # pylint: disable=C0103 @@ -173,79 +173,79 @@ def getMe(self): data = json.loads(str_response) return data - def getDevices(self): + def get_devices(self): """Gets device information.""" # pylint: disable=C0103 cmd = 'devices' - data = self._apiCall(cmd) + data = self._api_call(cmd) return data - def getZones(self): + def get_zones(self): """Gets zones information.""" # pylint: disable=C0103 cmd = 'zones' - data = self._apiCall(cmd) + data = self._api_call(cmd) return data - def getState(self, zone): + def get_state(self, zone): """Gets current state of Zone zone.""" # pylint: disable=C0103 cmd = 'zones/%i/state' % zone - data = self._apiCall(cmd) + data = self._api_call(cmd) return data - def getCapabilities(self, zone): + def get_capabilities(self, zone): """Gets current capabilities of Zone zone.""" # pylint: disable=C0103 cmd = 'zones/%i/capabilities' % zone - data = self._apiCall(cmd) + data = self._api_call(cmd) return data - def getClimate(self, zone): + def get_climate(self, zone): """Gets temp (centigrade) and humidity (% RH) for Zone zone.""" # pylint: disable=C0103 - data = self.getState(zone)['sensorDataPoints'] + data = self.get_state(zone)['sensorDataPoints'] return {'temperature' : data['insideTemperature']['celsius'], 'humidity' : data['humidity']['percentage']} - def getWeather(self): + def get_weather(self): """Gets outside weather data""" # pylint: disable=C0103 cmd = 'weather' - data = self._apiCall(cmd) + data = self._api_call(cmd) return data - def getAppUsers(self): + def get_app_users(self): """Gets getAppUsers data""" # pylint: disable=C0103 cmd = 'getAppUsers' - data = self._mobile_apiCall(cmd) + data = self._mobile_api_call(cmd) return data - def getAppUsersRelativePositions(self): + def get_app_users_relative_positions(self): """Gets getAppUsersRelativePositions data""" # pylint: disable=C0103 cmd = 'getAppUsersRelativePositions' - data = self._mobile_apiCall(cmd) + data = self._mobile_api_call(cmd) return data - def resetZoneOverlay(self, zone): + def reset_zone_overlay(self, zone): """Delete current overlay""" # pylint: disable=C0103 cmd = 'zones/%i/overlay' % zone - data = self._apiCall(cmd, "DELETE", {}, True) + data = self._api_call(cmd, "DELETE", {}, True) return data - def setZoneOverlay(self, zone, overlayMode, setTemp=None, duration=None, deviceType='HEATING', power="ON", mode=None): + def set_zone_overlay(self, zone, overlay_mode, set_temp=None, duration=None, device_type='HEATING', power="ON", mode=None): """set current overlay for a zone""" # pylint: disable=C0103 @@ -256,35 +256,35 @@ def setZoneOverlay(self, zone, overlayMode, setTemp=None, duration=None, deviceT "termination" : {} } - if setTemp is None: + if set_temp is None: post_data["setting"] = { - "type": deviceType, + "type": device_type, "power": power } elif mode is not None: post_data["setting"] = { - "type": deviceType, + "type": device_type, "power": power, "mode": mode, "temperature":{ - "celsius": setTemp + "celsius": set_temp } } else: post_data["setting"] = { - "type": deviceType, + "type": device_type, "power": power, "temperature":{ - "celsius": setTemp + "celsius": set_temp } } - post_data["termination"] = {"type" : overlayMode} + post_data["termination"] = {"type" : overlay_mode} if duration is not None: post_data["termination"]["durationInSeconds"] = duration - data = self._apiCall(cmd, "PUT", post_data) + data = self._api_call(cmd, "PUT", post_data) return data # Ctor @@ -298,5 +298,5 @@ def __init__(self, username, password): self.opener = urllib.request.build_opener( urllib.request.HTTPCookieProcessor(cj), urllib.request.HTTPSHandler()) - self._loginV2(username, password) - self.id = self.getMe()['homes'][0]['id'] + self._login_v2(username, password) + self.id = self.get_me()['homes'][0]['id'] From 974f9cf78e89fca6ba39b226d58d6aea700774a1 Mon Sep 17 00:00:00 2001 From: Petr Svoboda Date: Sun, 17 Nov 2024 11:10:22 +0100 Subject: [PATCH 2/4] add tests --- .gitignore | 2 ++ requirements.txt | 2 +- test_credentials.py | 6 ++++++ tests.py | 10 ++++++++++ 4 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 test_credentials.py create mode 100644 tests.py diff --git a/.gitignore b/.gitignore index 303626b..e78ca4c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ pytado.egg-info PyCharms .idea/ + +test_credentials.py \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 8b13789..e079f8a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ - +pytest diff --git a/test_credentials.py b/test_credentials.py new file mode 100644 index 0000000..29eb786 --- /dev/null +++ b/test_credentials.py @@ -0,0 +1,6 @@ +import pytest +from PyTado.interface import Tado + +@pytest.fixture +def tado_instance(): + return Tado('username', 'password') \ No newline at end of file diff --git a/tests.py b/tests.py new file mode 100644 index 0000000..81ee627 --- /dev/null +++ b/tests.py @@ -0,0 +1,10 @@ +from test_credentials import tado_instance + + +def test_get_me_keys(tado_instance): + result = tado_instance.get_me() + + expected_keys = ['name', 'email', 'username', 'id', 'homes', 'locale', 'mobileDevices'] + + for key in expected_keys: + assert key in result \ No newline at end of file From 3ad3e8c550a85c9afa05c083997aac59b32b3ec4 Mon Sep 17 00:00:00 2001 From: Petr Svoboda Date: Sun, 17 Nov 2024 14:34:06 +0100 Subject: [PATCH 3/4] redesign interfaces, add typing, tests and new hops api --- AUTHORS | 3 +- PyTado/interface.py | 232 +++++++++++++++++++++++++------------------- tests.py | 162 ++++++++++++++++++++++++++++++- 3 files changed, 295 insertions(+), 102 deletions(-) diff --git a/AUTHORS b/AUTHORS index 23aa474..7c5d6c2 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,3 +1,4 @@ Author: Chris Jewell Modified: Gareth Jeanne - Wolfgang Malgadey \ No newline at end of file + Wolfgang Malgadey + Petr Svoboda \ No newline at end of file diff --git a/PyTado/interface.py b/PyTado/interface.py index 32df437..36124f9 100644 --- a/PyTado/interface.py +++ b/PyTado/interface.py @@ -27,12 +27,12 @@ class Tado: headers = {'Referer' : 'https://my.tado.com/'} api2url = 'https://my.tado.com/api/v2/homes/' mobi2url = 'https://my.tado.com/mobile/1.9/' + hops2url = 'https://hops.tado.com/homes' refresh_token = '' refresh_at = datetime.datetime.now() + datetime.timedelta(minutes=5) # 'Private' methods for use in class, Tado mobile API V1.9. def _mobile_api_call(self, cmd): - # pylint: disable=C0103 self._refresh_token() @@ -54,7 +54,6 @@ def _mobile_api_call(self, cmd): # 'Private' methods for use in class, Tado API V2. def _api_call(self, cmd, method="GET", data=None, plain=False): - # pylint: disable=C0103 self._refresh_token() @@ -91,8 +90,45 @@ def _api_call(self, cmd, method="GET", data=None, plain=False): data = json.loads(str_response) return data + + # 'Private' methods for use in class, Tado API V2. + def _hops_api_call(self, cmd, method="GET", data=None, plain=False): + + self._refresh_token() + + headers = self.headers + + if data is not None: + if plain: + headers['Content-Type'] = 'text/plain;charset=UTF-8' + else: + headers['Content-Type'] = 'application/json;charset=UTF-8' + headers['Mime-Type'] = 'application/json;charset=UTF-8' + data = json.dumps(data).encode('utf8') + + if self._debugCalls: + _LOGGER.debug("api call: %s: %s, headers %s, data %s", + method, cmd, headers, data) + url = f'{self.hops2url}/{self.id}/{cmd}' + req = urllib.request.Request(url, + headers=headers, + method=method, + data=data) + + response = self.opener.open(req) + + if self._debugCalls: + _LOGGER.debug("api call: %s: %s, response %s", + method, cmd, response) + + str_response = response.read().decode('utf-8') + if str_response is None or str_response == "": + return + + data = json.loads(str_response) + return data + def _set_o_auth_header(self, data): - # pylint: disable=C0103 access_token = data['access_token'] expires_in = float(data['expires_in']) @@ -132,7 +168,6 @@ def _refresh_token(self): return response def _login_v2(self, username, password): - # pylint: disable=C0103 headers = self.headers headers['Content-Type'] = 'application/json' @@ -162,10 +197,8 @@ def set_debugging(self, debug_calls): return self._debugCalls # Public interface - def get_me(self): + def get_me(self) -> dict: """Gets home information.""" - # pylint: disable=C0103 - url = 'https://my.tado.com/api/v2/me' req = urllib.request.Request(url, headers=self.headers) response = self.opener.open(req) @@ -173,126 +206,129 @@ def get_me(self): data = json.loads(str_response) return data - def get_devices(self): - """Gets device information.""" - # pylint: disable=C0103 - - cmd = 'devices' - data = self._api_call(cmd) + def get_rooms_and_devices(self) -> dict: + """Gets room and device information.""" + cmd = 'roomsAndDevices' + data = self._hops_api_call(cmd) return data - def get_zones(self): - """Gets zones information.""" - # pylint: disable=C0103 - - cmd = 'zones' - data = self._api_call(cmd) + def get_rooms(self) -> list[dict]: + """Gets rooms information.""" + cmd = 'rooms' + data = self._hops_api_call(cmd) return data - def get_state(self, zone): - """Gets current state of Zone zone.""" - # pylint: disable=C0103 - - cmd = 'zones/%i/state' % zone - data = self._api_call(cmd) + def get_room(self, room_id: int) -> dict: + """Gets room information.""" + cmd = f'rooms/{room_id}' + data = self._hops_api_call(cmd) return data - def get_capabilities(self, zone): - """Gets current capabilities of Zone zone.""" - # pylint: disable=C0103 - - cmd = 'zones/%i/capabilities' % zone + def get_room_day_report(self, room_id: int, date: datetime.date) -> dict: + """Gets room day report with temperature, humidity, heating and weather information.""" + cmd = f'zones/{room_id}/dayReport?date={date.strftime('%Y-%m-%d')}' data = self._api_call(cmd) return data - def get_climate(self, zone): - """Gets temp (centigrade) and humidity (% RH) for Zone zone.""" - # pylint: disable=C0103 - - data = self.get_state(zone)['sensorDataPoints'] - return {'temperature' : data['insideTemperature']['celsius'], - 'humidity' : data['humidity']['percentage']} - - def get_weather(self): - """Gets outside weather data""" - # pylint: disable=C0103 - - cmd = 'weather' - data = self._api_call(cmd) + def get_air_comfort(self) -> dict: + """Gets air comfort information with current temperature, humidity and open window detection.""" + cmd = f'airComfort' + data = self._hops_api_call(cmd) return data - def get_app_users(self): - """Gets getAppUsers data""" - # pylint: disable=C0103 + def get_tado_mode(self) -> dict: + """Gets tado mode.""" + cmd = f'state' + data = self._api_call(cmd) + return data['presence'] - cmd = 'getAppUsers' - data = self._mobile_api_call(cmd) + def set_home(self) -> None: + """Sets tado mode to home.""" + cmd = f'presenceLock' + data = self._api_call(cmd, method="PUT", data={"homePresence": "HOME"}) return data - def get_app_users_relative_positions(self): - """Gets getAppUsersRelativePositions data""" - # pylint: disable=C0103 - - cmd = 'getAppUsersRelativePositions' - data = self._mobile_api_call(cmd) + def set_away(self) -> None: + """Sets tado mode to away.""" + cmd = f'presenceLock' + data = self._api_call(cmd, method="PUT", data={"homePresence": "AWAY"}) return data - def reset_zone_overlay(self, zone): - """Delete current overlay""" - # pylint: disable=C0103 - - cmd = 'zones/%i/overlay' % zone - data = self._api_call(cmd, "DELETE", {}, True) + def boost_heating(self) -> None: + """Boost mode, expires after 30 minutes.""" + cmd = f'quickActions/boost' + data = self._hops_api_call(cmd, method="POST") return data - def set_zone_overlay(self, zone, overlay_mode, set_temp=None, duration=None, device_type='HEATING', power="ON", mode=None): - """set current overlay for a zone""" - # pylint: disable=C0103 + def disable_heating(self) -> None: + """Sets all rooms off, frost protection.""" + cmd = f'quickActions/allOff' + data = self._hops_api_call(cmd, method="POST") + return data - cmd = 'zones/%i/overlay' % zone + def resume_schedule(self) -> None: + """Resumes regular schedule for all rooms, undo boost, disable heating and manual settings.""" + cmd = f'quickActions/resumeSchedule' + data = self._hops_api_call(cmd, method="POST") + return data - post_data = { - "setting" : {}, - "termination" : {} - } + def get_room_schedule(self, room_id: int) -> dict: + """Get room weekly schedule.""" + cmd = f'rooms/{room_id}/schedule' + data = self._hops_api_call(cmd, method="GET") + return data - if set_temp is None: - post_data["setting"] = { - "type": device_type, - "power": power - } - elif mode is not None: - post_data["setting"] = { - "type": device_type, - "power": power, - "mode": mode, - "temperature":{ - "celsius": set_temp - } - } - else: - post_data["setting"] = { - "type": device_type, - "power": power, - "temperature":{ - "celsius": set_temp + def set_room_schedule(self, room_id: int, schedule: dict) -> dict: + """Get room weekly schedule. Can set on day at a time. Sample payload: + { + "dayType": "SUNDAY", + "daySchedule": [ + { + "start": "00:00", + "end": "07:00", + "dayType": "SUNDAY", + "setting": { + "power": "ON", + "temperature": { + "value": 19 + } + } + }, + { + "start": "07:00", + "end": "22:00", + "dayType": "SUNDAY", + "setting": { + "power": "ON", + "temperature": { + "value": 21 + } + } + }, + { + "start": "22:00", + "end": "24:00", + "dayType": "SUNDAY", + "setting": { + "power": "ON", + "temperature": { + "value": 19 + } + } } - } - - post_data["termination"] = {"type" : overlay_mode} - - if duration is not None: - post_data["termination"]["durationInSeconds"] = duration - - data = self._api_call(cmd, "PUT", post_data) + ] + } + """ + cmd = f'rooms/{room_id}/schedule' + data = self._hops_api_call(cmd, method="POST", data=schedule) return data + # Ctor def __init__(self, username, password): """Performs login and save session cookie.""" # HTTPS Interface - # pylint: disable=C0103 cj = CookieJar() self.opener = urllib.request.build_opener( diff --git a/tests.py b/tests.py index 81ee627..7d1e34a 100644 --- a/tests.py +++ b/tests.py @@ -1,10 +1,166 @@ from test_credentials import tado_instance +import datetime -def test_get_me_keys(tado_instance): +def test_get_me(tado_instance): result = tado_instance.get_me() - + assert isinstance(result, dict) expected_keys = ['name', 'email', 'username', 'id', 'homes', 'locale', 'mobileDevices'] + for key in expected_keys: + assert key in result + + +def test_get_rooms_and_devices(tado_instance): + result = tado_instance.get_rooms_and_devices() + assert isinstance(result, dict) + expected_keys = ['rooms', 'otherDevices'] + for key in expected_keys: + assert key in result + + +def test_get_rooms(tado_instance): + result = tado_instance.get_rooms() + assert isinstance(result, list) + for room in result: + expected_keys = ['id', 'name'] + assert isinstance(room['id'], int) + for key in expected_keys: + assert key in room + + +def test_get_room(tado_instance): + room = tado_instance.get_room(1) + expected_keys = ['id', 'name'] + assert isinstance(room['id'], int) + for key in expected_keys: + assert key in room + +def test_get_room_day_report(tado_instance): + report = tado_instance.get_room_day_report(1, datetime.date.today()) + expected_keys = ['zoneType', 'interval', 'hoursInDay', 'measuredData', 'stripes', 'settings', 'callForHeat', 'weather'] + assert isinstance(report, dict) for key in expected_keys: - assert key in result \ No newline at end of file + assert key in report + + +# def test_get_room_day_report_w_datetime(tado_instance): +# report = tado_instance.get_room_day_report(1, datetime.datetime.now()) +# expected_keys = ['zoneType', 'interval', 'hoursInDay', 'measuredData', 'stripes', 'settings', 'callForHeat', 'weather'] +# assert isinstance(report, dict) +# for key in expected_keys: +# assert key in report +# +# +# def test_get_room_day_report_w_pendulum(tado_instance): +# report = tado_instance.get_room_day_report(1, pendulum.now()) +# expected_keys = ['zoneType', 'interval', 'hoursInDay', 'measuredData', 'stripes', 'settings', 'callForHeat', 'weather'] +# assert isinstance(report, dict) +# for key in expected_keys: +# assert key in report + + +def test_get_air_comfort(tado_instance): + report = tado_instance.get_air_comfort() + expected_keys = ['freshness', 'comfort'] + assert isinstance(report, dict) + for key in expected_keys: + assert key in report + + +def test_get_tado_mode(tado_instance): + mode = tado_instance.get_tado_mode() + assert isinstance(mode, str) + + +def test_set_away(tado_instance): + response = tado_instance.set_away() + mode = tado_instance.get_tado_mode() + assert mode == "AWAY" + assert response is None + + +def test_set_home(tado_instance): + response = tado_instance.set_home() + mode = tado_instance.get_tado_mode() + assert mode == "HOME" + assert response is None + + +def test_boost_heating(tado_instance): + response = tado_instance.boost_heating() + boost_mode = tado_instance.get_rooms()[0].get('boostMode') + assert boost_mode is not None + assert response is None + + +def test_disable_heating(tado_instance): + response = tado_instance.disable_heating() + power = tado_instance.get_rooms()[0]['setting'].get('power') + assert power == 'OFF' + assert response is None + + +def test_resume_schedule(tado_instance): + response = tado_instance.resume_schedule() + room = tado_instance.get_rooms()[0] + power = room['setting'].get('power') + boost_mode = room.get('boostMode') + assert power == 'ON' + assert boost_mode is None + assert response is None + + +def test_get_room_schedule(tado_instance): + schedule = tado_instance.get_room_schedule(1) + expected_keys = ['room', 'otherRooms', 'schedule'] + assert isinstance(schedule, dict) + for key in expected_keys: + assert key in schedule + + +def test_set_room_schedule(tado_instance): + data = { + "dayType": "SUNDAY", + "daySchedule": [ + { + "start": "00:00", + "end": "07:00", + "dayType": "SUNDAY", + "setting": { + "power": "ON", + "temperature": { + "value": 19 + } + } + }, + { + "start": "07:00", + "end": "22:00", + "dayType": "SUNDAY", + "setting": { + "power": "ON", + "temperature": { + "value": 21 + } + } + }, + { + "start": "22:00", + "end": "24:00", + "dayType": "SUNDAY", + "setting": { + "power": "ON", + "temperature": { + "value": 19 + } + } + } + ] + } + response = tado_instance.set_room_schedule(1, schedule=data) + assert response is None + schedule = tado_instance.get_room_schedule(1)['schedule'] + assert data['daySchedule'][0] in schedule + + From 8339e127d5c456af246a2a72db612422430107a7 Mon Sep 17 00:00:00 2001 From: Petr Svoboda Date: Sun, 17 Nov 2024 14:44:16 +0100 Subject: [PATCH 4/4] update readme example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fc47ced..07921cb 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Example basic usage >>> from PyTado.interface import Tado >>> t = Tado('my@username.com', 'mypassword') - >>> climate = t.getClimate(zone=1) + >>> climate = t.get_room_schedule(1) Development -----------