From cc9647fc02a5b58328bc7ee63ca699d409ed7daf Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Thu, 19 Dec 2024 14:14:33 +0100 Subject: [PATCH 01/49] added tests for http class --- PyTado/const.py | 6 + PyTado/http.py | 15 +- pyproject.toml | 2 +- requirements.txt | 1 + tests/fixtures/tadox/homes_response.json | 46 ++++++ tests/test_http.py | 180 +++++++++++++++++++++++ 6 files changed, 244 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/tadox/homes_response.json create mode 100644 tests/test_http.py diff --git a/PyTado/const.py b/PyTado/const.py index 860c0a7..58c04a8 100644 --- a/PyTado/const.py +++ b/PyTado/const.py @@ -1,5 +1,11 @@ """Constant values for the Tado component.""" +# Api credentials +CLIENT_ID = "tado-web-app" +CLIENT_SECRET = ( + "wZaRN7rpjn3FoNyF5IFuxg9uMzYJcvOoQ8QWiIqS3hfk6gLhVlG57j5YNoZL2Rtc" +) + # Types TYPE_AIR_CONDITIONING = "AIR_CONDITIONING" TYPE_HEATING = "HEATING" diff --git a/PyTado/http.py b/PyTado/http.py index bfa52d7..2c883fb 100644 --- a/PyTado/http.py +++ b/PyTado/http.py @@ -11,6 +11,7 @@ import requests +from PyTado.const import CLIENT_ID, CLIENT_SECRET from PyTado.exceptions import TadoException, TadoWrongCredentialsException from PyTado.logger import Logger @@ -251,10 +252,8 @@ def _refresh_token(self) -> None: url = "https://auth.tado.com/oauth/token" data = { - "client_id": "tado-web-app", - "client_secret": ( - "wZaRN7rpjn3FoNyF5IFuxg9uMzYJcvOoQ8QWiIqS3hfk6gLhVlG57j5YNoZL2Rtc" - ), + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, "grant_type": "refresh_token", "scope": "home.user", "refresh_token": self._token_refresh, @@ -275,7 +274,13 @@ def _refresh_token(self) -> None: }, ) - self._set_oauth_header(response.json()) + if response.status_code == 200: + self._set_oauth_header(response.json()) + return + + raise TadoException( + f"Unknown error while refreshing token with status code {response.status_code}" + ) def _login(self) -> tuple[int, bool, str] | None: diff --git a/pyproject.toml b/pyproject.toml index 6be4534..bed8071 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ classifiers = [ GitHub = "https://github.com/wmalgadey/PyTado" [project.optional-dependencies] -dev = ["black>=24.3", "pytype", "pylint", "types-requests", "coverage", "pytest", "pytest-cov"] +dev = ["black>=24.3", "pytype", "pylint", "types-requests", "responses", "coverage", "pytest", "pytest-cov"] [project.scripts] pytado = "pytado.__main__:main" diff --git a/requirements.txt b/requirements.txt index f229360..76c28c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ requests +responses diff --git a/tests/fixtures/tadox/homes_response.json b/tests/fixtures/tadox/homes_response.json new file mode 100644 index 0000000..9bb9cb0 --- /dev/null +++ b/tests/fixtures/tadox/homes_response.json @@ -0,0 +1,46 @@ +{ + "id": 1234, + "name": "My Home", + "dateTimeZone": "Europe/Berlin", + "dateCreated": "2024-12-04T21:53:35.862Z", + "temperatureUnit": "CELSIUS", + "partner": null, + "simpleSmartScheduleEnabled": true, + "awayRadiusInMeters": 400.00, + "installationCompleted": true, + "incidentDetection": {"supported": false, "enabled": true}, + "generation": "LINE_X", + "zonesCount": 0, + "language": "de-DE", + "preventFromSubscribing": true, + "skills": [], + "christmasModeEnabled": true, + "showAutoAssistReminders": true, + "contactDetails": { + "name": "Alice Wonderland", + "email": "alice-in@wonder.land", + "phone": "+00000000" + }, + "address": { + "addressLine1": "Wonderland 1", + "addressLine2": null, + "zipCode": "112", + "city": "Wonderland", + "state": null, + "country": "DEU" + }, + "geolocation": {"latitude": 25.1532934, "longitude": 2.3324432}, + "consentGrantSkippable": true, + "enabledFeatures": [ + "AA_REVERSE_TRIAL_7D", + "EIQ_SETTINGS_AS_WEBVIEW", + "HIDE_BOILER_REPAIR_SERVICE", + "OWD_SETTINGS_AS_WEBVIEW", + "SETTINGS_OVERVIEW_AS_WEBVIEW" + ], + "isAirComfortEligible": false, + "isBalanceAcEligible": false, + "isEnergyIqEligible": true, + "isHeatSourceInstalled": false, + "isHeatPumpInstalled": false +} \ No newline at end of file diff --git a/tests/test_http.py b/tests/test_http.py new file mode 100644 index 0000000..b13f073 --- /dev/null +++ b/tests/test_http.py @@ -0,0 +1,180 @@ +"""Test the Http class.""" + +from datetime import datetime, timedelta +import json +import responses +import unittest + +from PyTado.const import CLIENT_ID, CLIENT_SECRET +from PyTado.exceptions import TadoException, TadoWrongCredentialsException + +from . import common + +from PyTado.http import Http + + +class TestHttp(unittest.TestCase): + """Testcases for Http class.""" + + def setUp(self): + super().setUp() + + # Mock the login response + responses.add( + responses.POST, + "https://auth.tado.com/oauth/token", + json={ + "access_token": "value", + "expires_in": 1234, + "refresh_token": "another_value", + }, + status=200, + ) + + responses.add( + responses.GET, + "https://my.tado.com/api/v2/homes/1234/", + json={"homes": [{"id": 1234}]}, + status=200, + ) + + # Mock the get me response + responses.add( + responses.GET, + "https://my.tado.com/api/v2/me", + json={"homes": [{"id": 1234}]}, + status=200, + ) + + @responses.activate + def test_login_successful(self): + + instance = Http( + username="test_user", + password="test_pass", + debug=True, + ) + + # Verify that the login was successful + self.assertEqual(instance._id, 1234) + self.assertEqual(instance.is_x_line, False) + + @responses.activate + def test_login_failed(self): + + responses.replace( + responses.POST, + "https://auth.tado.com/oauth/token", + json={"error": "invalid_grant"}, + status=400, + ) + + with self.assertRaises( + expected_exception=TadoWrongCredentialsException, + msg="Your username or password is invalid", + ): + Http( + username="test_user", + password="test_pass", + debug=True, + ) + + responses.replace( + responses.POST, + "https://auth.tado.com/oauth/token", + json={"error": "server failed"}, + status=503, + ) + + with self.assertRaises( + expected_exception=TadoException, + msg="Login failed for unknown reason with status code 503", + ): + Http( + username="test_user", + password="test_pass", + debug=True, + ) + + @responses.activate + def test_line_x(self): + + responses.replace( + responses.GET, + "https://my.tado.com/api/v2/homes/1234/", + json=json.loads(common.load_fixture("tadox/homes_response.json")), + status=200, + ) + + instance = Http( + username="test_user", + password="test_pass", + debug=True, + ) + + # Verify that the login was successful + self.assertEqual(instance._id, 1234) + self.assertEqual(instance.is_x_line, True) + + @responses.activate + def test_refresh_token_success(self): + instance = Http( + username="test_user", + password="test_pass", + debug=True, + ) + + expected_params = { + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "grant_type": "refresh_token", + "scope": "home.user", + "refresh_token": "another_value", + } + # Mock the refresh token response + refresh_token = responses.replace( + responses.POST, + "https://auth.tado.com/oauth/token", + match=[ + responses.matchers.query_param_matcher(expected_params), + ], + json={ + "access_token": "new_value", + "expires_in": 1234, + "refresh_token": "new_refresh_value", + }, + status=200, + ) + + # Force token refresh + instance._refresh_at = datetime.now() - timedelta(seconds=1) + instance._refresh_token() + + assert refresh_token.call_count == 1 + + # Verify that the token was refreshed + self.assertEqual(instance._headers["Authorization"], "Bearer new_value") + + @responses.activate + def test_refresh_token_failure(self): + instance = Http( + username="test_user", + password="test_pass", + debug=True, + ) + + # Mock the refresh token response with failure + refresh_token = responses.replace( + responses.POST, + "https://auth.tado.com/oauth/token", + json={"error": "invalid_grant"}, + status=400, + ) + + # Force token refresh + instance._refresh_at = datetime.now() - timedelta(seconds=1) + + with self.assertRaises(TadoException): + instance._refresh_token() + + assert refresh_token.call_count == 1 From 1d86fa6b9d5038abdfad4ae7b70f791b2efdb064 Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Thu, 19 Dec 2024 19:58:10 +0100 Subject: [PATCH 02/49] removed obsolete method to create the request class --- PyTado/interface/api/hops_tado.py | 21 +++++----- PyTado/interface/api/my_tado.py | 67 +++++++++++++++---------------- 2 files changed, 41 insertions(+), 47 deletions(-) diff --git a/PyTado/interface/api/hops_tado.py b/PyTado/interface/api/hops_tado.py index d21c174..6217d76 100644 --- a/PyTado/interface/api/hops_tado.py +++ b/PyTado/interface/api/hops_tado.py @@ -56,15 +56,12 @@ def __init__( # set to None until explicitly set self._auto_geofencing_supported = None - def _create_x_request(self) -> TadoRequest: - return TadoXRequest() - def get_devices(self): """ Gets device information. """ - request = self._create_x_request() + request = TadoXRequest() request.command = "roomsAndDevices" rooms: list[dict[str, Any]] = self._http.request(request)["rooms"] @@ -77,7 +74,7 @@ def get_zones(self): Gets zones information. """ - request = self._create_x_request() + request = TadoXRequest() request.command = "roomsAndDevices" return self._http.request(request)["rooms"] @@ -94,7 +91,7 @@ def get_zone_states(self): Gets current states of all zones. """ - request = self._create_x_request() + request = TadoXRequest() request.command = "rooms" return self._http.request(request) @@ -104,7 +101,7 @@ def get_state(self, zone): Gets current state of Zone. """ - request = self._create_x_request() + request = TadoXRequest() request.command = f"rooms/{zone:d}" data = self._http.request(request) @@ -150,7 +147,7 @@ def get_schedule( Zone has 3 different schedules, one for each timetable (see setTimetable) """ - request = self._create_x_request() + request = TadoXRequest() request.command = f"rooms/{zone:d}/schedule" return self._http.request(request) @@ -160,7 +157,7 @@ def set_schedule(self, zone, timetable: Timetable, day, data): Set the schedule for a zone, day is required """ - request = self._create_x_request() + request = TadoXRequest() request.command = f"rooms/{zone:d}/schedule" request.action = Action.SET request.payload = data @@ -173,7 +170,7 @@ def reset_zone_overlay(self, zone): Delete current overlay """ - request = self._create_x_request() + request = TadoXRequest() request.command = f"rooms/{zone:d}/resumeSchedule" request.action = Action.SET @@ -213,7 +210,7 @@ def set_zone_overlay( if duration is not None: post_data["termination"]["durationInSeconds"] = duration - request = self._create_x_request() + request = TadoXRequest() request.command = f"rooms/{zone:d}/manualControl" request.action = Action.SET request.payload = post_data @@ -275,7 +272,7 @@ def set_temp_offset(self, device_id, offset=0, measure="celsius"): Set the Temperature offset on the device. """ - request = self._create_x_request() + request = TadoXRequest() request.command = f"roomsAndDevices/devices/{device_id}" request.action = Action.CHANGE request.payload = {"temperatureOffset": offset} diff --git a/PyTado/interface/api/my_tado.py b/PyTado/interface/api/my_tado.py index 71735b0..5ce3230 100644 --- a/PyTado/interface/api/my_tado.py +++ b/PyTado/interface/api/my_tado.py @@ -65,15 +65,12 @@ def __init__( # set to None until explicitly set self._auto_geofencing_supported = None - def _create_request(self) -> TadoRequest: - return TadoRequest() - def get_me(self): """ Gets home information. """ - request = self._create_request() + request = TadoRequest() request.action = Action.GET request.domain = Domain.ME @@ -84,7 +81,7 @@ def get_devices(self): Gets device information. """ - request = self._create_request() + request = TadoRequest() request.command = "devices" return self._http.request(request) @@ -93,7 +90,7 @@ def get_zones(self): Gets zones information. """ - request = self._create_request() + request = TadoRequest() request.command = "zones" return self._http.request(request) @@ -110,7 +107,7 @@ def get_zone_states(self): Gets current states of all zones. """ - request = self._create_request() + request = TadoRequest() request.command = "zoneStates" return self._http.request(request) @@ -120,7 +117,7 @@ def get_state(self, zone): Gets current state of Zone. """ - request = self._create_request() + request = TadoRequest() request.command = f"zones/{zone}/state" data = { **self._http.request(request), @@ -150,7 +147,7 @@ def get_home_state(self): # indicates whether presence is current locked (manually set) to # HOME or AWAY or not locked (automatically set based on geolocation) - request = self._create_request() + request = TadoRequest() request.command = "state" data = self._http.request(request) @@ -186,7 +183,7 @@ def get_capabilities(self, zone): Gets current capabilities of zone. """ - request = self._create_request() + request = TadoRequest() request.command = f"zones/{zone:d}/capabilities" return self._http.request(request) @@ -207,7 +204,7 @@ def get_timetable(self, zone: int) -> Timetable: Get the Timetable type currently active """ - request = self._create_request() + request = TadoRequest() request.command = f"zones/{zone:d}/schedule/activeTimetable" request.mode = Mode.PLAIN data = self._http.request(request) @@ -231,7 +228,7 @@ def get_historic(self, zone, date): "Incorrect date format, should be YYYY-MM-DD" ) from err - request = self._create_request() + request = TadoRequest() request.command = ( f"zones/{zone:d}/dayReport?date={day.strftime('%Y-%m-%d')}" ) @@ -245,7 +242,7 @@ def set_timetable(self, zone: int, timetable: Timetable) -> None: id = 3 : SEVEN_DAY (MONDAY, TUESDAY, WEDNESDAY ...) """ - request = self._create_request() + request = TadoRequest() request.command = f"zones/{zone:d}/schedule/activeTimetable" request.action = Action.CHANGE request.payload = {"id": timetable} @@ -260,7 +257,7 @@ def get_schedule( Get the JSON representation of the schedule for a zone. Zone has 3 different schedules, one for each timetable (see setTimetable) """ - request = self._create_request() + request = TadoRequest() if day: request.command = ( f"zones/{zone:d}/schedule/timetables/{timetable:d}/blocks/{day}" @@ -278,7 +275,7 @@ def set_schedule(self, zone, timetable: Timetable, day, data): Set the schedule for a zone, day is required """ - request = self._create_request() + request = TadoRequest() request.command = ( f"zones/{zone:d}/schedule/timetables/{timetable:d}/blocks/{day}" ) @@ -293,7 +290,7 @@ def get_weather(self): Gets outside weather data """ - request = self._create_request() + request = TadoRequest() request.command = "weather" return self._http.request(request) @@ -303,7 +300,7 @@ def get_air_comfort(self): Gets air quality information """ - request = self._create_request() + request = TadoRequest() request.command = "airComfort" return self._http.request(request) @@ -313,7 +310,7 @@ def get_users(self): Gets active users in home """ - request = self._create_request() + request = TadoRequest() request.command = "users" return self._http.request(request) @@ -323,7 +320,7 @@ def get_mobile_devices(self): Gets information about mobile devices """ - request = self._create_request() + request = TadoRequest() request.command = "mobileDevices" return self._http.request(request) @@ -333,7 +330,7 @@ def reset_zone_overlay(self, zone): Delete current overlay """ - request = self._create_request() + request = TadoRequest() request.command = f"zones/{zone:d}/overlay" request.action = Action.RESET request.mode = Mode.PLAIN @@ -384,7 +381,7 @@ def set_zone_overlay( if duration is not None: post_data["termination"]["durationInSeconds"] = duration - request = self._create_request() + request = TadoRequest() request.command = f"zones/{zone:d}/overlay" request.action = Action.CHANGE request.payload = post_data @@ -396,7 +393,7 @@ def get_zone_overlay_default(self, zone: int): Get current overlay default settings for zone. """ - request = self._create_request() + request = TadoRequest() request.command = f"zones/{zone:d}/defaultOverlay" return self._http.request(request) @@ -420,7 +417,7 @@ def change_presence(self, presence: Presence) -> None: Sets HomeState to presence """ - request = self._create_request() + request = TadoRequest() request.command = "presenceLock" request.action = Action.CHANGE request.payload = {"homePresence": presence} @@ -434,7 +431,7 @@ def set_auto(self) -> None: # Only attempt to set Auto Geofencing if it is believed to be supported if self._auto_geofencing_supported: - request = self._create_request() + request = TadoRequest() request.command = "presenceLock" request.action = Action.RESET @@ -469,7 +466,7 @@ def set_open_window(self, zone): Note: This can only be set if an open window was detected in this zone """ - request = self._create_request() + request = TadoRequest() request.command = f"zones/{zone:d}/state/openWindow/activate" request.action = Action.SET request.mode = Mode.PLAIN @@ -481,7 +478,7 @@ def reset_open_window(self, zone): Sets the window in zone to closed """ - request = self._create_request() + request = TadoRequest() request.command = f"zones/{zone:d}/state/openWindow" request.action = Action.RESET request.mode = Mode.PLAIN @@ -494,7 +491,7 @@ def get_device_info(self, device_id, cmd=""): with option to get specific info i.e. cmd='temperatureOffset' """ - request = self._create_request() + request = TadoRequest() request.command = cmd request.action = Action.GET request.domain = Domain.DEVICES @@ -507,7 +504,7 @@ def set_temp_offset(self, device_id, offset=0, measure="celsius"): Set the Temperature offset on the device. """ - request = self._create_request() + request = TadoRequest() request.command = "temperatureOffset" request.action = Action.CHANGE request.domain = Domain.DEVICES @@ -521,7 +518,7 @@ def get_eiq_tariffs(self): Get Energy IQ tariff history """ - request = self._create_request() + request = TadoRequest() request.command = "tariffs" request.action = Action.GET request.endpoint = Endpoint.EIQ @@ -533,7 +530,7 @@ def get_eiq_meter_readings(self): Get Energy IQ meter readings """ - request = self._create_request() + request = TadoRequest() request.command = "meterReadings" request.action = Action.GET request.endpoint = Endpoint.EIQ @@ -547,7 +544,7 @@ def set_eiq_meter_readings( Send Meter Readings to Tado, date format is YYYY-MM-DD, reading is without decimals """ - request = self._create_request() + request = TadoRequest() request.command = "meterReadings" request.action = Action.SET request.endpoint = Endpoint.EIQ @@ -585,7 +582,7 @@ def set_eiq_tariff( "startDate": from_date, } - request = self._create_request() + request = TadoRequest() request.command = "tariffs" request.action = Action.SET request.endpoint = Endpoint.EIQ @@ -598,7 +595,7 @@ def get_heating_circuits(self): Gets available heating circuits """ - request = self._create_request() + request = TadoRequest() request.command = "heatingCircuits" return self._http.request(request) @@ -608,7 +605,7 @@ def get_zone_control(self, zone): Get zone control information """ - request = self._create_request() + request = TadoRequest() request.command = f"zones/{zone:d}/control" return self._http.request(request) @@ -618,7 +615,7 @@ def set_zone_heating_circuit(self, zone, heating_circuit): Sets the heating circuit for a zone """ - request = self._create_request() + request = TadoRequest() request.command = f"zones/{zone:d}/control/heatingCircuit" request.action = Action.CHANGE request.payload = {"circuitNumber": heating_circuit} From 68b852185ede1f200722212b8392cb5d68be5ce3 Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Thu, 19 Dec 2024 19:58:30 +0100 Subject: [PATCH 03/49] add types --- PyTado/zone/my_zone.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PyTado/zone/my_zone.py b/PyTado/zone/my_zone.py index 5dda9b7..349a279 100644 --- a/PyTado/zone/my_zone.py +++ b/PyTado/zone/my_zone.py @@ -60,13 +60,13 @@ class TadoZone: tado_mode: str | None = None overlay_termination_type: str | None = None overlay_termination_timestamp: str | None = None + default_overlay_termination_type: str | None = None + default_overlay_termination_duration: str | None = None preparation: bool = False open_window: bool = False open_window_detected: bool = False open_window_attr: dict[str, Any] = dataclasses.field(default_factory=dict) precision: float = DEFAULT_TADO_PRECISION - default_overlay_termination_type = None - default_overlay_termination_duration = None @property def overlay_active(self) -> bool: From 41e0b330257e3fac070c5c119ffdaa03910259dc Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Thu, 19 Dec 2024 19:59:18 +0100 Subject: [PATCH 04/49] add test for hops tado class --- tests/fixtures/tadox/rooms_response.json | 40 +++++++++++++++++++ tests/test_hops_zone.py | 50 ++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 tests/fixtures/tadox/rooms_response.json create mode 100644 tests/test_hops_zone.py diff --git a/tests/fixtures/tadox/rooms_response.json b/tests/fixtures/tadox/rooms_response.json new file mode 100644 index 0000000..3dcbe3c --- /dev/null +++ b/tests/fixtures/tadox/rooms_response.json @@ -0,0 +1,40 @@ +{ + "id": 14, + "name": "Wohnzimmer", + "sensorDataPoints": { + "insideTemperature": { + "value": 22.19 + }, + "humidity": { + "percentage": 41 + } + }, + "setting": { + "power": "ON", + "temperature": { + "value": 22.0 + } + }, + "manualControlTermination": null, + "boostMode": null, + "heatingPower": { + "percentage": 21 + }, + "connection": { + "state": "CONNECTED" + }, + "openWindow": null, + "nextScheduleChange": { + "start": "2024-12-19T21:00:00Z", + "setting": { + "power": "ON", + "temperature": { + "value": 18.0 + } + } + }, + "nextTimeBlock": { + "start": "2024-12-19T21:00:00Z" + }, + "balanceControl": null +} \ No newline at end of file diff --git a/tests/test_hops_zone.py b/tests/test_hops_zone.py new file mode 100644 index 0000000..a262da6 --- /dev/null +++ b/tests/test_hops_zone.py @@ -0,0 +1,50 @@ +"""Test the TadoZone object.""" + +import json +import unittest +from unittest import mock + +from . import common + +from PyTado.http import Http +from PyTado.interface.api import TadoX + + +class TadoZoneTestCase(unittest.TestCase): + """Test cases for zone class""" + + def setUp(self) -> None: + super().setUp() + login_patch = mock.patch( + "PyTado.http.Http._login", return_value=(1, "foo") + ) + is_x_line_patch = mock.patch( + "PyTado.http.Http._check_x_line_generation", return_value=True + ) + get_me_patch = mock.patch("PyTado.interface.api.Tado.get_me") + login_patch.start() + is_x_line_patch.start() + get_me_patch.start() + self.addCleanup(login_patch.stop) + self.addCleanup(is_x_line_patch.stop) + self.addCleanup(get_me_patch.stop) + + self.http = Http("my@username.com", "mypassword") + self.tado_client = TadoX(self.http) + + def set_fixture(self, filename: str) -> None: + get_state_patch = mock.patch( + "PyTado.interface.api.TadoX.get_state", + return_value=json.loads(common.load_fixture(filename)), + ) + get_state_patch.start() + self.addCleanup(get_state_patch.stop) + + def test_get_zone_state(self): + """Test general homes response.""" + + self.set_fixture("tadox/homes_response.json") + mode = self.tado_client.get_zone_state(14) + + assert mode.preparation is False + assert mode.zone_id == 14 From 6239dfb9d6476dd4d0acf338b41a48e452720ec6 Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Thu, 19 Dec 2024 19:59:49 +0100 Subject: [PATCH 05/49] bumped version to 0.18.4 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f15073d..6cbbad5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-tado" -version = "0.18.3" +version = "0.18.4" description = "PyTado from chrism0dwk, modfied by w.malgadey, diplix, michaelarnauts, LenhartStephan, splifter, syssi, andersonshatch, Yippy, p0thi, Coffee2CodeNL, chiefdragon, FilBr, nikilase, albertomontesg, Moritz-Schmidt, palazzem" authors = [ { name = "Chris Jewell", email = "chrism0dwk@gmail.com" }, From cdc32cdc2d713ccad7a15388dac6fc52581ccc23 Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Thu, 19 Dec 2024 20:07:41 +0100 Subject: [PATCH 06/49] small refactorings --- PyTado/zone/hops_zone.py | 10 ++++++---- PyTado/zone/my_zone.py | 27 +++++++++++++++------------ 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/PyTado/zone/hops_zone.py b/PyTado/zone/hops_zone.py index 2991395..a7dcc87 100644 --- a/PyTado/zone/hops_zone.py +++ b/PyTado/zone/hops_zone.py @@ -7,7 +7,6 @@ from typing import Any, Self from PyTado.const import ( - CONST_HORIZONTAL_SWING_OFF, CONST_HVAC_HEAT, CONST_HVAC_IDLE, CONST_HVAC_OFF, @@ -15,6 +14,7 @@ CONST_MODE_HEAT, CONST_MODE_OFF, CONST_VERTICAL_SWING_OFF, + CONST_HORIZONTAL_SWING_OFF, ) from .my_zone import TadoZone @@ -127,16 +127,18 @@ def from_data(cls, zone_id: int, data: dict[str, Any]) -> Self: kwargs["overlay_termination_timestamp"] = None # Connection state and availability - kwargs["connection"] = data.get("connectionState", {}).get("value") + kwargs["connection"] = data.get("connectionState", {}).get( + "value", None + ) kwargs["available"] = kwargs.get("link") != CONST_LINK_OFFLINE # Termination conditions if "terminationCondition" in data: kwargs["default_overlay_termination_type"] = data[ "terminationCondition" - ].get("type") + ].get("type", None) kwargs["default_overlay_termination_duration"] = data[ "terminationCondition" - ].get("durationInSeconds") + ].get("durationInSeconds", None) return cls(zone_id=zone_id, **kwargs) diff --git a/PyTado/zone/my_zone.py b/PyTado/zone/my_zone.py index 349a279..81c6c83 100644 --- a/PyTado/zone/my_zone.py +++ b/PyTado/zone/my_zone.py @@ -82,8 +82,9 @@ def from_data(cls, zone_id: int, data: dict[str, Any]) -> Self: sensor_data = data["sensorDataPoints"] if "insideTemperature" in sensor_data: - temperature = float(sensor_data["insideTemperature"]["celsius"]) - kwargs["current_temp"] = temperature + kwargs["current_temp"] = float( + sensor_data["insideTemperature"]["celsius"] + ) kwargs["current_temp_timestamp"] = sensor_data[ "insideTemperature" ]["timestamp"] @@ -115,21 +116,23 @@ def from_data(cls, zone_id: int, data: dict[str, Any]) -> Self: "temperature" in data["setting"] and data["setting"]["temperature"] is not None ): - target_temperature = float( + kwargs["target_temp"] = float( data["setting"]["temperature"]["celsius"] ) - kwargs["target_temp"] = target_temperature setting = data["setting"] - kwargs["current_fan_speed"] = None - kwargs["current_fan_level"] = None - # If there is no overlay, the mode will always be - # "SMART_SCHEDULE" - kwargs["current_hvac_mode"] = CONST_MODE_OFF - kwargs["current_swing_mode"] = CONST_MODE_OFF - kwargs["current_vertical_swing_mode"] = CONST_VERTICAL_SWING_OFF - kwargs["current_horizontal_swing_mode"] = CONST_HORIZONTAL_SWING_OFF + # Reset modes and settings + kwargs.update( + { + "current_fan_speed": None, + "current_fan_level": None, + "current_hvac_mode": CONST_MODE_OFF, + "current_swing_mode": CONST_MODE_OFF, + "current_vertical_swing_mode": CONST_VERTICAL_SWING_OFF, + "current_horizontal_swing_mode": CONST_HORIZONTAL_SWING_OFF, + } + ) if "mode" in setting: # v3 devices use mode From a31820215a9d12d6c3f1724e24f21f77fd771322 Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Thu, 19 Dec 2024 23:35:40 +0100 Subject: [PATCH 07/49] added tests for tado x heating --- tests/fixtures/home_1234/my_api_v2_me.json | 76 +++++++++++++ .../tadov2.my_api_v2_home_state.json | 45 ++++++++ .../tadox.heating.auto_mode.json} | 10 +- .../home_1234/tadox.heating.manual_mode.json | 44 +++++++ .../home_1234/tadox.heating.manual_off.json | 42 +++++++ .../fixtures/home_1234/tadox.hops_homes.json | 6 + .../tadox.my_api_v2_home_state.json} | 2 +- .../tadox/hops_tado_homes_features.json | 6 + ...ado_homes_programmer_domesticHotWater.json | 4 + ...mes_quickActions_boost_boostableZones.json | 3 + tests/test_hops_zone.py | 107 +++++++++++++++++- 11 files changed, 334 insertions(+), 11 deletions(-) create mode 100644 tests/fixtures/home_1234/my_api_v2_me.json create mode 100644 tests/fixtures/home_1234/tadov2.my_api_v2_home_state.json rename tests/fixtures/{tadox/rooms_response.json => home_1234/tadox.heating.auto_mode.json} (85%) create mode 100644 tests/fixtures/home_1234/tadox.heating.manual_mode.json create mode 100644 tests/fixtures/home_1234/tadox.heating.manual_off.json create mode 100644 tests/fixtures/home_1234/tadox.hops_homes.json rename tests/fixtures/{tadox/homes_response.json => home_1234/tadox.my_api_v2_home_state.json} (97%) create mode 100644 tests/fixtures/tadox/hops_tado_homes_features.json create mode 100644 tests/fixtures/tadox/hops_tado_homes_programmer_domesticHotWater.json create mode 100644 tests/fixtures/tadox/hops_tado_homes_quickActions_boost_boostableZones.json diff --git a/tests/fixtures/home_1234/my_api_v2_me.json b/tests/fixtures/home_1234/my_api_v2_me.json new file mode 100644 index 0000000..c2e4a92 --- /dev/null +++ b/tests/fixtures/home_1234/my_api_v2_me.json @@ -0,0 +1,76 @@ +{ + "name": "Alice Wonderland", + "email": "alice-in@wonder.land", + "username": "alice-in@wonder.land", + "id": "123a1234567b89012cde1f23", + "homes": [ + { + "id": 1234, + "name": "Test Home" + } + ], + "locale": "de_DE", + "mobileDevices": [ + { + "name": "iPad", + "id": 1234567, + "settings": { + "geoTrackingEnabled": false, + "specialOffersEnabled": true, + "onDemandLogRetrievalEnabled": false, + "pushNotifications": { + "lowBatteryReminder": true, + "awayModeReminder": true, + "homeModeReminder": true, + "openWindowReminder": true, + "energySavingsReportReminder": true, + "incidentDetection": true, + "energyIqReminder": true, + "tariffHighPriceAlert": true, + "tariffLowPriceAlert": true + } + }, + "deviceMetadata": { + "platform": "iOS", + "osVersion": "18.0", + "model": "iPad8,10", + "locale": "de" + } + }, + { + "name": "iPhone", + "id": 12345678, + "settings": { + "geoTrackingEnabled": true, + "specialOffersEnabled": true, + "onDemandLogRetrievalEnabled": false, + "pushNotifications": { + "lowBatteryReminder": true, + "awayModeReminder": true, + "homeModeReminder": true, + "openWindowReminder": true, + "energySavingsReportReminder": true, + "incidentDetection": true, + "energyIqReminder": true, + "tariffHighPriceAlert": true, + "tariffLowPriceAlert": true + } + }, + "location": { + "stale": false, + "atHome": true, + "bearingFromHome": { + "degrees": 90.0, + "radians": 1.5707963267948966 + }, + "relativeDistanceFromHomeFence": 0.0 + }, + "deviceMetadata": { + "platform": "iOS", + "osVersion": "18.2", + "model": "iPhone14,5", + "locale": "de" + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/home_1234/tadov2.my_api_v2_home_state.json b/tests/fixtures/home_1234/tadov2.my_api_v2_home_state.json new file mode 100644 index 0000000..fe6bf63 --- /dev/null +++ b/tests/fixtures/home_1234/tadov2.my_api_v2_home_state.json @@ -0,0 +1,45 @@ +{ + "id": 1234, + "name": "My Home - Tado v1-v3+", + "dateTimeZone": "Europe/Berlin", + "dateCreated": "2024-12-04T21:53:35.862Z", + "temperatureUnit": "CELSIUS", + "partner": null, + "simpleSmartScheduleEnabled": true, + "awayRadiusInMeters": 400.00, + "installationCompleted": true, + "incidentDetection": {"supported": false, "enabled": true}, + "zonesCount": 0, + "language": "de-DE", + "preventFromSubscribing": true, + "skills": [], + "christmasModeEnabled": true, + "showAutoAssistReminders": true, + "contactDetails": { + "name": "Alice Wonderland", + "email": "alice-in@wonder.land", + "phone": "+00000000" + }, + "address": { + "addressLine1": "Wonderland 1", + "addressLine2": null, + "zipCode": "112", + "city": "Wonderland", + "state": null, + "country": "DEU" + }, + "geolocation": {"latitude": 25.1532934, "longitude": 2.3324432}, + "consentGrantSkippable": true, + "enabledFeatures": [ + "AA_REVERSE_TRIAL_7D", + "EIQ_SETTINGS_AS_WEBVIEW", + "HIDE_BOILER_REPAIR_SERVICE", + "OWD_SETTINGS_AS_WEBVIEW", + "SETTINGS_OVERVIEW_AS_WEBVIEW" + ], + "isAirComfortEligible": false, + "isBalanceAcEligible": false, + "isEnergyIqEligible": true, + "isHeatSourceInstalled": false, + "isHeatPumpInstalled": false +} \ No newline at end of file diff --git a/tests/fixtures/tadox/rooms_response.json b/tests/fixtures/home_1234/tadox.heating.auto_mode.json similarity index 85% rename from tests/fixtures/tadox/rooms_response.json rename to tests/fixtures/home_1234/tadox.heating.auto_mode.json index 3dcbe3c..b5bb1ac 100644 --- a/tests/fixtures/tadox/rooms_response.json +++ b/tests/fixtures/home_1234/tadox.heating.auto_mode.json @@ -1,12 +1,12 @@ { - "id": 14, - "name": "Wohnzimmer", + "id": 1, + "name": "Room 1", "sensorDataPoints": { "insideTemperature": { - "value": 22.19 + "value": 24.0 }, "humidity": { - "percentage": 41 + "percentage": 38 } }, "setting": { @@ -18,7 +18,7 @@ "manualControlTermination": null, "boostMode": null, "heatingPower": { - "percentage": 21 + "percentage": 100 }, "connection": { "state": "CONNECTED" diff --git a/tests/fixtures/home_1234/tadox.heating.manual_mode.json b/tests/fixtures/home_1234/tadox.heating.manual_mode.json new file mode 100644 index 0000000..be91f9c --- /dev/null +++ b/tests/fixtures/home_1234/tadox.heating.manual_mode.json @@ -0,0 +1,44 @@ +{ + "id": 1, + "name": "Room 1", + "sensorDataPoints": { + "insideTemperature": { + "value": 24.07 + }, + "humidity": { + "percentage": 38 + } + }, + "setting": { + "power": "ON", + "temperature": { + "value": 25.5 + } + }, + "manualControlTermination": { + "type": "NEXT_TIME_BLOCK", + "remainingTimeInSeconds": 4549, + "projectedExpiry": "2024-12-19T21:00:00Z" + }, + "boostMode": null, + "heatingPower": { + "percentage": 100 + }, + "connection": { + "state": "CONNECTED" + }, + "openWindow": null, + "nextScheduleChange": { + "start": "2024-12-19T21:00:00Z", + "setting": { + "power": "ON", + "temperature": { + "value": 18.0 + } + } + }, + "nextTimeBlock": { + "start": "2024-12-19T21:00:00Z" + }, + "balanceControl": null +} \ No newline at end of file diff --git a/tests/fixtures/home_1234/tadox.heating.manual_off.json b/tests/fixtures/home_1234/tadox.heating.manual_off.json new file mode 100644 index 0000000..2eecb6b --- /dev/null +++ b/tests/fixtures/home_1234/tadox.heating.manual_off.json @@ -0,0 +1,42 @@ +{ + "id": 1, + "name": "Room 1", + "sensorDataPoints": { + "insideTemperature": { + "value": 24.08 + }, + "humidity": { + "percentage": 38 + } + }, + "setting": { + "power": "OFF", + "temperature": null + }, + "manualControlTermination": { + "type": "NEXT_TIME_BLOCK", + "remainingTimeInSeconds": 4497, + "projectedExpiry": "2024-12-19T21:00:00Z" + }, + "boostMode": null, + "heatingPower": { + "percentage": 35 + }, + "connection": { + "state": "CONNECTED" + }, + "openWindow": null, + "nextScheduleChange": { + "start": "2024-12-19T21:00:00Z", + "setting": { + "power": "ON", + "temperature": { + "value": 18.0 + } + } + }, + "nextTimeBlock": { + "start": "2024-12-19T21:00:00Z" + }, + "balanceControl": null +} \ No newline at end of file diff --git a/tests/fixtures/home_1234/tadox.hops_homes.json b/tests/fixtures/home_1234/tadox.hops_homes.json new file mode 100644 index 0000000..b236fb6 --- /dev/null +++ b/tests/fixtures/home_1234/tadox.hops_homes.json @@ -0,0 +1,6 @@ +{ + "roomCount": 2, + "isHeatSourceInstalled": false, + "isHeatPumpInstalled": false, + "supportsFlowTemperatureOptimization": false +} \ No newline at end of file diff --git a/tests/fixtures/tadox/homes_response.json b/tests/fixtures/home_1234/tadox.my_api_v2_home_state.json similarity index 97% rename from tests/fixtures/tadox/homes_response.json rename to tests/fixtures/home_1234/tadox.my_api_v2_home_state.json index 9bb9cb0..0cebbb0 100644 --- a/tests/fixtures/tadox/homes_response.json +++ b/tests/fixtures/home_1234/tadox.my_api_v2_home_state.json @@ -1,6 +1,6 @@ { "id": 1234, - "name": "My Home", + "name": "My Home - TadoX", "dateTimeZone": "Europe/Berlin", "dateCreated": "2024-12-04T21:53:35.862Z", "temperatureUnit": "CELSIUS", diff --git a/tests/fixtures/tadox/hops_tado_homes_features.json b/tests/fixtures/tadox/hops_tado_homes_features.json new file mode 100644 index 0000000..61d7af8 --- /dev/null +++ b/tests/fixtures/tadox/hops_tado_homes_features.json @@ -0,0 +1,6 @@ +{ + "availableFeatures": [ + "geofencing", + "openWindowDetection" + ] +} \ No newline at end of file diff --git a/tests/fixtures/tadox/hops_tado_homes_programmer_domesticHotWater.json b/tests/fixtures/tadox/hops_tado_homes_programmer_domesticHotWater.json new file mode 100644 index 0000000..5a91ea6 --- /dev/null +++ b/tests/fixtures/tadox/hops_tado_homes_programmer_domesticHotWater.json @@ -0,0 +1,4 @@ +{ + "isDomesticHotWaterCapable": false, + "domesticHotWaterInterface": "NONE" +} \ No newline at end of file diff --git a/tests/fixtures/tadox/hops_tado_homes_quickActions_boost_boostableZones.json b/tests/fixtures/tadox/hops_tado_homes_quickActions_boost_boostableZones.json new file mode 100644 index 0000000..54ac8e1 --- /dev/null +++ b/tests/fixtures/tadox/hops_tado_homes_quickActions_boost_boostableZones.json @@ -0,0 +1,3 @@ +{ + "zones": [] +} \ No newline at end of file diff --git a/tests/test_hops_zone.py b/tests/test_hops_zone.py index a262da6..8b7fbb8 100644 --- a/tests/test_hops_zone.py +++ b/tests/test_hops_zone.py @@ -33,18 +33,115 @@ def setUp(self) -> None: self.tado_client = TadoX(self.http) def set_fixture(self, filename: str) -> None: + def check_get_state(zone_id): + assert zone_id == 1 + return json.loads(common.load_fixture(filename)) + get_state_patch = mock.patch( "PyTado.interface.api.TadoX.get_state", - return_value=json.loads(common.load_fixture(filename)), + side_effect=check_get_state, ) get_state_patch.start() self.addCleanup(get_state_patch.stop) - def test_get_zone_state(self): + def test_tadox_heating_auto_mode(self): + """Test general homes response.""" + + self.set_fixture("home_1234/tadox.heating.auto_mode.json") + mode = self.tado_client.get_zone_state(1) + + assert mode.ac_power is None + assert mode.ac_power_timestamp is None + assert mode.available is True + assert mode.connection == "CONNECTED" + assert mode.current_fan_speed is None + assert mode.current_humidity == 38.00 + assert mode.current_humidity_timestamp is None + assert mode.current_hvac_action == "HEATING" + assert mode.current_hvac_mode == "SMART_SCHEDULE" + assert mode.current_swing_mode == "OFF" + assert mode.current_temp == 24.00 + assert mode.current_temp_timestamp is None + assert mode.heating_power is None + assert mode.heating_power_percentage == 100.0 + assert mode.heating_power_timestamp is None + assert mode.is_away is None + assert mode.link is None + assert mode.open_window is False + assert not mode.open_window_attr + assert mode.overlay_active is False + assert mode.overlay_termination_type is None + assert mode.power == "ON" + assert mode.precision == 0.01 + assert mode.preparation is False + assert mode.tado_mode is None + assert mode.target_temp == 22.0 + assert mode.zone_id == 1 + + def test_tadox_heating_manual_mode(self): + """Test general homes response.""" + + self.set_fixture("home_1234/tadox.heating.manual_mode.json") + mode = self.tado_client.get_zone_state(1) + + assert mode.ac_power is None + assert mode.ac_power_timestamp is None + assert mode.available is True + assert mode.connection == "CONNECTED" + assert mode.current_fan_speed is None + assert mode.current_humidity == 38.00 + assert mode.current_humidity_timestamp is None + assert mode.current_hvac_action == "HEATING" + assert mode.current_hvac_mode == "HEAT" + assert mode.current_swing_mode == "OFF" + assert mode.current_temp == 24.07 + assert mode.current_temp_timestamp is None + assert mode.heating_power is None + assert mode.heating_power_percentage == 100.0 + assert mode.heating_power_timestamp is None + assert mode.is_away is None + assert mode.link is None + assert mode.open_window is False + assert not mode.open_window_attr + assert mode.overlay_active is True + assert mode.overlay_termination_type == "NEXT_TIME_BLOCK" + assert mode.power == "ON" + assert mode.precision == 0.01 + assert mode.preparation is False + assert mode.tado_mode is None + assert mode.target_temp == 25.5 + assert mode.zone_id == 1 + + def test_tadox_heating_manual_off(self): """Test general homes response.""" - self.set_fixture("tadox/homes_response.json") - mode = self.tado_client.get_zone_state(14) + self.set_fixture("home_1234/tadox.heating.manual_off.json") + mode = self.tado_client.get_zone_state(1) + assert mode.ac_power is None + assert mode.ac_power_timestamp is None + assert mode.available is True + assert mode.connection == "CONNECTED" + assert mode.current_fan_speed is None + assert mode.current_humidity == 38.00 + assert mode.current_humidity_timestamp is None + assert mode.current_hvac_action == "OFF" + assert mode.current_hvac_mode == "OFF" + assert mode.current_swing_mode == "OFF" + assert mode.current_temp == 24.08 + assert mode.current_temp_timestamp is None + assert mode.heating_power is None + assert mode.heating_power_percentage == 0.0 + assert mode.heating_power_timestamp is None + assert mode.is_away is None + assert mode.link is None + assert mode.open_window is False + assert not mode.open_window_attr + assert mode.overlay_active is True + assert mode.overlay_termination_type == "NEXT_TIME_BLOCK" + assert mode.power == "OFF" + assert mode.precision == 0.01 assert mode.preparation is False - assert mode.zone_id == 14 + assert mode.tado_mode is None + assert mode.target_temp is None + assert mode.zone_id == 1 From 4e1840226df84f67f3e1c5dcd3378c2a5d5f664e Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Thu, 19 Dec 2024 23:36:23 +0100 Subject: [PATCH 08/49] fixed tado x hvac mode and connection state --- PyTado/const.py | 2 ++ PyTado/zone/hops_zone.py | 21 +++++++++++++-------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/PyTado/const.py b/PyTado/const.py index 58c04a8..83b64b5 100644 --- a/PyTado/const.py +++ b/PyTado/const.py @@ -21,6 +21,7 @@ CONST_MODE_FAN = "FAN" CONST_LINK_OFFLINE = "OFFLINE" +CONST_CONNECTION_OFFLINE = "OFFLINE" CONST_FAN_OFF = "OFF" CONST_FAN_AUTO = "AUTO" @@ -99,6 +100,7 @@ ] DEFAULT_TADO_PRECISION = 0.1 +DEFAULT_TADOX_PRECISION = 0.01 HOME_DOMAIN = "homes" DEVICE_DOMAIN = "devices" diff --git a/PyTado/zone/hops_zone.py b/PyTado/zone/hops_zone.py index a7dcc87..d893b66 100644 --- a/PyTado/zone/hops_zone.py +++ b/PyTado/zone/hops_zone.py @@ -10,11 +10,13 @@ CONST_HVAC_HEAT, CONST_HVAC_IDLE, CONST_HVAC_OFF, - CONST_LINK_OFFLINE, + CONST_CONNECTION_OFFLINE, CONST_MODE_HEAT, CONST_MODE_OFF, + CONST_MODE_SMART_SCHEDULE, CONST_VERTICAL_SWING_OFF, CONST_HORIZONTAL_SWING_OFF, + DEFAULT_TADOX_PRECISION, ) from .my_zone import TadoZone @@ -26,6 +28,8 @@ class TadoXZone(TadoZone): """Tado Zone data structure for hops.tado.com (Tado X) API.""" + precision: float = DEFAULT_TADOX_PRECISION + @classmethod def from_data(cls, zone_id: int, data: dict[str, Any]) -> Self: """Handle update callbacks for X zones with specific parsing.""" @@ -37,9 +41,9 @@ def from_data(cls, zone_id: int, data: dict[str, Any]) -> Self: sensor_data = data["sensorDataPoints"] if "insideTemperature" in sensor_data: - kwargs["current_temp"] = float( - sensor_data["insideTemperature"]["value"] - ) + inside_temp = sensor_data["insideTemperature"] + if "value" in inside_temp: + kwargs["current_temp"] = float(inside_temp["value"]) kwargs["current_temp_timestamp"] = None if "precision" in sensor_data["insideTemperature"]: kwargs["precision"] = sensor_data["insideTemperature"][ @@ -123,14 +127,15 @@ def from_data(cls, zone_id: int, data: dict[str, Any]) -> Self: manual_termination["projectedExpiry"] ) else: + kwargs["current_hvac_mode"] = CONST_MODE_SMART_SCHEDULE kwargs["overlay_termination_type"] = None kwargs["overlay_termination_timestamp"] = None + else: + kwargs["current_hvac_mode"] = CONST_MODE_SMART_SCHEDULE - # Connection state and availability - kwargs["connection"] = data.get("connectionState", {}).get( - "value", None + kwargs["available"] = ( + kwargs.get("connection") != CONST_CONNECTION_OFFLINE ) - kwargs["available"] = kwargs.get("link") != CONST_LINK_OFFLINE # Termination conditions if "terminationCondition" in data: From cc91edbeb4f942f6bf147fc865c967fdac12b304 Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Thu, 19 Dec 2024 23:37:23 +0100 Subject: [PATCH 09/49] made some changes to tadox devices - added better documentation for tado x changes --- PyTado/interface/api/hops_tado.py | 76 +++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 10 deletions(-) diff --git a/PyTado/interface/api/hops_tado.py b/PyTado/interface/api/hops_tado.py index 6217d76..bd824f1 100644 --- a/PyTado/interface/api/hops_tado.py +++ b/PyTado/interface/api/hops_tado.py @@ -12,6 +12,7 @@ from ...exceptions import TadoNotSupportedException from ...http import ( Action, + Domain, Http, Mode, TadoRequest, @@ -28,7 +29,7 @@ class TadoX(Tado): Example usage: http = Http('me@somewhere.com', 'mypasswd') t = TadoX(http) - t.get_climate(1) # Get climate, zone 1. + t.get_climate(1) # Get climate, room 1. """ def __init__( @@ -64,14 +65,24 @@ def get_devices(self): request = TadoXRequest() request.command = "roomsAndDevices" - rooms: list[dict[str, Any]] = self._http.request(request)["rooms"] + rooms_and_devices: list[dict[str, Any]] = self._http.request(request) + rooms = rooms_and_devices["rooms"] + devices = [device for room in rooms for device in room["devices"]] + for device in devices: + request = TadoXRequest() + request.command = f"devices/{device["serialNo"]:s}" + device.update(self._http.request(request)) + + if "otherDevices" in rooms_and_devices: + devices.append(rooms_and_devices["otherDevices"]) + return devices def get_zones(self): """ - Gets zones information. + Gets zones (or rooms in Tado X API) information. """ request = TadoXRequest() @@ -136,7 +147,7 @@ def set_timetable(self, zone: int, timetable: Timetable) -> None: """ raise TadoNotSupportedException( - "This method is not currently supported by the Tado X API" + "Tado X API only support seven days timetable" ) def get_schedule( @@ -154,7 +165,44 @@ def get_schedule( def set_schedule(self, zone, timetable: Timetable, day, data): """ - Set the schedule for a zone, day is required + Set the schedule for a zone, day is not required for Tado X API. + + example data + [ + { + "start": "00:00", + "end": "07:05", + "dayType": "MONDAY", + "setting": { + "power": "ON", + "temperature": { + "value": 18 + } + } + }, + { + "start": "07:05", + "end": "22:05", + "dayType": "MONDAY", + "setting": { + "power": "ON", + "temperature": { + "value": 22 + } + } + }, + { + "start": "22:05", + "end": "24:00", + "dayType": "MONDAY", + "setting": { + "power": "ON", + "temperature": { + "value": 18 + } + } + } + ] """ request = TadoXRequest() @@ -192,7 +240,7 @@ def set_zone_overlay( horizontal_swing=None, ): """ - Set current overlay for a zone + Set current overlay for a zone, a room in Tado X API. """ post_data = { @@ -223,7 +271,7 @@ def get_zone_overlay_default(self, zone: int): """ raise TadoNotSupportedException( - "This method is not currently supported by the Tado X API" + "Concept of zones is not available by Tado X API, they use rooms" ) def get_open_window_detected(self, zone): @@ -263,9 +311,17 @@ def get_device_info(self, device_id, cmd=""): with option to get specific info i.e. cmd='temperatureOffset' """ - raise TadoNotSupportedException( - "This method is not currently supported by the Tado X API" - ) + if cmd: + request = TadoRequest() + request.command = cmd + else: + request = TadoXRequest() + + request.action = Action.GET + request.domain = Domain.DEVICES + request.device = device_id + + return self._http.request(request) def set_temp_offset(self, device_id, offset=0, measure="celsius"): """ From 9083ea9799593c000efc2b3cc6ce585842281201 Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Thu, 19 Dec 2024 23:38:37 +0100 Subject: [PATCH 10/49] added tariff and genie api --- PyTado/http.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/PyTado/http.py b/PyTado/http.py index 8d325cf..486af1c 100644 --- a/PyTado/http.py +++ b/PyTado/http.py @@ -25,6 +25,8 @@ class Endpoint(enum.StrEnum): HOPS_API = "https://hops.tado.com/" MOBILE = "https://my.tado.com/mobile/1.9/" EIQ = "https://energy-insights.tado.com/api/" + TARIFF = "https://tariff-experience.tado.com/api/" + GENIE = "https://genie.tado.com/api/v2/" class Domain(enum.StrEnum): From c860f979ee7c977de65cb709e406133e631ff684 Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Thu, 19 Dec 2024 23:38:55 +0100 Subject: [PATCH 11/49] added better tests to http class --- tests/test_http.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/test_http.py b/tests/test_http.py index b13f073..eb09dae 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -25,7 +25,7 @@ def setUp(self): "https://auth.tado.com/oauth/token", json={ "access_token": "value", - "expires_in": 1234, + "expires_in": 1000, "refresh_token": "another_value", }, status=200, @@ -33,16 +33,19 @@ def setUp(self): responses.add( responses.GET, - "https://my.tado.com/api/v2/homes/1234/", - json={"homes": [{"id": 1234}]}, + "https://my.tado.com/api/v2/me", + json=json.loads(common.load_fixture("home_1234/my_api_v2_me.json")), status=200, ) - # Mock the get me response responses.add( responses.GET, - "https://my.tado.com/api/v2/me", - json={"homes": [{"id": 1234}]}, + "https://my.tado.com/api/v2/homes/1234/", + json=json.loads( + common.load_fixture( + "home_1234/tadov2.my_api_v2_home_state.json" + ) + ), status=200, ) @@ -102,7 +105,9 @@ def test_line_x(self): responses.replace( responses.GET, "https://my.tado.com/api/v2/homes/1234/", - json=json.loads(common.load_fixture("tadox/homes_response.json")), + json=json.loads( + common.load_fixture("home_1234/tadox.my_api_v2_home_state.json") + ), status=200, ) From 077e11955186456b81fd5d1074fcaba1cd3cf76a Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Thu, 19 Dec 2024 23:41:52 +0100 Subject: [PATCH 12/49] fixed error --- PyTado/interface/api/hops_tado.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PyTado/interface/api/hops_tado.py b/PyTado/interface/api/hops_tado.py index bd824f1..2c086b9 100644 --- a/PyTado/interface/api/hops_tado.py +++ b/PyTado/interface/api/hops_tado.py @@ -72,7 +72,7 @@ def get_devices(self): for device in devices: request = TadoXRequest() - request.command = f"devices/{device["serialNo"]:s}" + request.command = "devices/" + device["serialNo"] device.update(self._http.request(request)) if "otherDevices" in rooms_and_devices: From 9b6967ff5e41f86699b966c322c1536f295f7f9e Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Thu, 19 Dec 2024 23:43:43 +0100 Subject: [PATCH 13/49] fixed devices call --- PyTado/interface/api/hops_tado.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/PyTado/interface/api/hops_tado.py b/PyTado/interface/api/hops_tado.py index 2c086b9..befd806 100644 --- a/PyTado/interface/api/hops_tado.py +++ b/PyTado/interface/api/hops_tado.py @@ -72,7 +72,8 @@ def get_devices(self): for device in devices: request = TadoXRequest() - request.command = "devices/" + device["serialNo"] + request.domain = Domain.DEVICES + request.device = device["serialNo"] device.update(self._http.request(request)) if "otherDevices" in rooms_and_devices: From 8c5cf4d3d134517d918c904ae08bb06bec947c65 Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Fri, 20 Dec 2024 10:34:46 +0100 Subject: [PATCH 14/49] added test to termination condition --- ...my_api_issue_88.termination_condition.json | 80 +++++++++++++++++++ tests/test_my_zone.py | 32 ++++++++ 2 files changed, 112 insertions(+) create mode 100644 tests/fixtures/my_api_issue_88.termination_condition.json diff --git a/tests/fixtures/my_api_issue_88.termination_condition.json b/tests/fixtures/my_api_issue_88.termination_condition.json new file mode 100644 index 0000000..5715b60 --- /dev/null +++ b/tests/fixtures/my_api_issue_88.termination_condition.json @@ -0,0 +1,80 @@ +{ + "tadoMode": "HOME", + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "preparation": null, + "setting": { + "type": "HEATING", + "power": "ON", + "temperature": { + "celsius": 22.00, + "fahrenheit": 71.60 + } + }, + "overlayType": "MANUAL", + "overlay": { + "type": "MANUAL", + "setting": { + "type": "HEATING", + "power": "ON", + "temperature": { + "celsius": 22.00, + "fahrenheit": 71.60 + } + }, + "termination": { + "type": "TIMER", + "typeSkillBasedApp": "TIMER", + "durationInSeconds": 1433, + "expiry": "2024-12-19T14:38:04Z", + "remainingTimeInSeconds": 1300, + "projectedExpiry": "2024-12-19T14:38:04Z" + } + }, + "openWindow": null, + "nextScheduleChange": null, + "nextTimeBlock": { + "start": "2024-12-20T07:30:00.000Z" + }, + "link": { + "state": "ONLINE" + }, + "runningOfflineSchedule": false, + "activityDataPoints": { + "heatingPower": { + "type": "PERCENTAGE", + "percentage": 100.00, + "timestamp": "2024-12-19T14:14:15.558Z" + } + }, + "sensorDataPoints": { + "insideTemperature": { + "celsius": 16.20, + "fahrenheit": 61.16, + "timestamp": "2024-12-19T14:14:52.404Z", + "type": "TEMPERATURE", + "precision": { + "celsius": 0.1, + "fahrenheit": 0.1 + } + }, + "humidity": { + "type": "PERCENTAGE", + "percentage": 64.40, + "timestamp": "2024-12-19T14:14:52.404Z" + } + }, + "type": "MANUAL", + "termination": { + "type": "TIMER", + "typeSkillBasedApp": "TIMER", + "durationInSeconds": 1300, + "expiry": "2024-12-19T14:38:09Z", + "remainingTimeInSeconds": 1299, + "projectedExpiry": "2024-12-19T14:38:09Z" + }, + "terminationCondition": { + "type": "TIMER", + "durationInSeconds": 1300 + } +} \ No newline at end of file diff --git a/tests/test_my_zone.py b/tests/test_my_zone.py index 029eb33..fe7b611 100644 --- a/tests/test_my_zone.py +++ b/tests/test_my_zone.py @@ -67,6 +67,38 @@ def test_ac_issue_32294_heat_mode(self): assert mode.precision == 0.1 assert mode.current_swing_mode == "OFF" + def test_my_api_issue_88(self): + """Test smart ac cool mode.""" + self.set_fixture("my_api_issue_88.termination_condition.json") + mode = self.tado_client.get_zone_state(1) + + assert mode.ac_power is None + assert mode.ac_power_timestamp is None + assert mode.available is True + assert mode.connection is None + assert mode.current_fan_speed is None + assert mode.current_humidity == 64.40 + assert mode.current_humidity_timestamp == "2024-12-19T14:14:52.404Z" + assert mode.current_hvac_action == "HEATING" + assert mode.current_hvac_mode == "HEAT" + assert mode.current_swing_mode == "OFF" + assert mode.current_temp == 16.2 + assert mode.current_temp_timestamp == "2024-12-19T14:14:52.404Z" + assert mode.heating_power is None + assert mode.heating_power_percentage == 100.0 + assert mode.heating_power_timestamp == "2024-12-19T14:14:15.558Z" + assert mode.is_away is False + assert mode.link == "ONLINE" + assert mode.open_window is False + assert not mode.open_window_attr + assert mode.overlay_active + assert mode.overlay_termination_type == "TIMER" + assert mode.power == "ON" + assert mode.precision == 0.1 + assert mode.preparation is False + assert mode.tado_mode == "HOME" + assert mode.target_temp == 22.0 + def test_smartac3_smart_mode(self): """Test smart ac smart mode.""" self.set_fixture("smartac3.smart_mode.json") From 8750982268ce6571df2b587666f6069a692c2274 Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Fri, 20 Dec 2024 11:07:37 +0100 Subject: [PATCH 15/49] test to output code coverage in PR and action summary --- .github/workflows/pythonpackage.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index fcd53a2..3d0d98d 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -44,6 +44,30 @@ jobs: coverage run -m pytest --maxfail=1 --disable-warnings -q coverage report -m coverage html + coverage xml + + - name: Code Coverage Report + uses: irongut/CodeCoverageSummary@v1.3.0 + with: + filename: coverage.xml + badge: true + fail_below_min: true + format: markdown + hide_branch_rate: false + hide_complexity: true + indicators: true + output: both + thresholds: '60 80' + + - name: Add Coverage PR Comment + uses: marocchino/sticky-pull-request-comment@v2 + if: github.event_name == 'pull_request' + with: + recreate: true + path: code-coverage-results.md + + - name: Output code coverage results to GitHub step summary + run: cat code-coverage-results.md >> $GITHUB_STEP_SUMMARY # Optionally upload coverage reports as an artifact. - name: Upload coverage report From 10b8ff27cba7c6dbeaf7b5892aeb82eb36cc58d2 Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Fri, 20 Dec 2024 11:21:29 +0100 Subject: [PATCH 16/49] changed workflow settings --- .github/workflows/pythonpackage.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 3d0d98d..23d300d 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -54,7 +54,7 @@ jobs: fail_below_min: true format: markdown hide_branch_rate: false - hide_complexity: true + hide_complexity: false indicators: true output: both thresholds: '60 80' @@ -71,7 +71,7 @@ jobs: # Optionally upload coverage reports as an artifact. - name: Upload coverage report - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: coverage-html-report path: coverage_html_report \ No newline at end of file From 49693296691635f07e3865c58808b3b6715390a4 Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Fri, 20 Dec 2024 11:30:59 +0100 Subject: [PATCH 17/49] renamed workflows for better transparency --- .../{codeql.yml => codeql-advanced.yml} | 0 .github/workflows/lint-and-test-matrix.yml | 45 +++++++++++++++++++ ...python-publish.yml => publish-to-pypi.yml} | 0 ...honpackage.yml => report-test-results.yml} | 19 ++------ 4 files changed, 48 insertions(+), 16 deletions(-) rename .github/workflows/{codeql.yml => codeql-advanced.yml} (100%) create mode 100644 .github/workflows/lint-and-test-matrix.yml rename .github/workflows/{python-publish.yml => publish-to-pypi.yml} (100%) rename .github/workflows/{pythonpackage.yml => report-test-results.yml} (71%) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql-advanced.yml similarity index 100% rename from .github/workflows/codeql.yml rename to .github/workflows/codeql-advanced.yml diff --git a/.github/workflows/lint-and-test-matrix.yml b/.github/workflows/lint-and-test-matrix.yml new file mode 100644 index 0000000..209c2d6 --- /dev/null +++ b/.github/workflows/lint-and-test-matrix.yml @@ -0,0 +1,45 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Lint and test + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -e . + + - name: Lint with black + uses: psf/black@stable + with: + options: "--check --verbose" + src: "./PyTado" + use_pyproject: true + + - name: Run Tests with Coverage + run: | + pip install coverage pytest pytest-cov + coverage run -m pytest --maxfail=1 --disable-warnings -q + coverage report -m \ No newline at end of file diff --git a/.github/workflows/python-publish.yml b/.github/workflows/publish-to-pypi.yml similarity index 100% rename from .github/workflows/python-publish.yml rename to .github/workflows/publish-to-pypi.yml diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/report-test-results.yml similarity index 71% rename from .github/workflows/pythonpackage.yml rename to .github/workflows/report-test-results.yml index 23d300d..3bcbd6f 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/report-test-results.yml @@ -1,7 +1,4 @@ -# This workflow will install Python dependencies, run tests and lint with a variety of Python versions -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions - -name: Python package +name: Output test results on: push: @@ -13,17 +10,14 @@ jobs: build: runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.11", "3.12"] steps: - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} + - name: Set up Python 3.12 uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + python-version: 3.12 - name: Install dependencies run: | @@ -31,13 +25,6 @@ jobs: pip install -r requirements.txt pip install -e . - - name: Lint with black - uses: psf/black@stable - with: - options: "--check --verbose" - src: "./PyTado" - use_pyproject: true - - name: Run Tests with Coverage run: | pip install coverage pytest pytest-cov From a5254bdc0a202fd0cab4ca4d357b16decb9733ec Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Fri, 20 Dec 2024 11:34:57 +0100 Subject: [PATCH 18/49] changed job names --- .github/workflows/lint-and-test-matrix.yml | 27 +++++++++++++++++++--- .github/workflows/report-test-results.yml | 2 +- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/.github/workflows/lint-and-test-matrix.yml b/.github/workflows/lint-and-test-matrix.yml index 209c2d6..635ff3f 100644 --- a/.github/workflows/lint-and-test-matrix.yml +++ b/.github/workflows/lint-and-test-matrix.yml @@ -1,7 +1,7 @@ # This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions -name: Lint and test +name: Python package on: push: @@ -10,7 +10,7 @@ on: branches: [ master ] jobs: - build: + lint: runs-on: ubuntu-latest strategy: @@ -37,7 +37,28 @@ jobs: options: "--check --verbose" src: "./PyTado" use_pyproject: true - + + test: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -e . + - name: Run Tests with Coverage run: | pip install coverage pytest pytest-cov diff --git a/.github/workflows/report-test-results.yml b/.github/workflows/report-test-results.yml index 3bcbd6f..bfff7a2 100644 --- a/.github/workflows/report-test-results.yml +++ b/.github/workflows/report-test-results.yml @@ -7,7 +7,7 @@ on: branches: [ master ] jobs: - build: + report: runs-on: ubuntu-latest From e86f8c5c75b41f22d614b2d4e438800435c999cf Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Fri, 20 Dec 2024 11:36:44 +0100 Subject: [PATCH 19/49] renamed worflows --- .github/workflows/codeql-advanced.yml | 11 ----------- .github/workflows/lint-and-test-matrix.yml | 2 +- .github/workflows/publish-to-pypi.yml | 2 +- 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/.github/workflows/codeql-advanced.yml b/.github/workflows/codeql-advanced.yml index 059de14..c1b05f9 100644 --- a/.github/workflows/codeql-advanced.yml +++ b/.github/workflows/codeql-advanced.yml @@ -1,14 +1,3 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# name: "CodeQL Advanced" on: diff --git a/.github/workflows/lint-and-test-matrix.yml b/.github/workflows/lint-and-test-matrix.yml index 635ff3f..66fd6b7 100644 --- a/.github/workflows/lint-and-test-matrix.yml +++ b/.github/workflows/lint-and-test-matrix.yml @@ -1,7 +1,7 @@ # This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions -name: Python package +name: Lint and test multiple Python versions on: push: diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 6a82b72..221a279 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -1,4 +1,4 @@ -name: Upload Python Package +name: Build and deploy to pypi on: release: From b543811d70c715a9c0c7a8911c9c1507e72da527 Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Fri, 20 Dec 2024 11:40:17 +0100 Subject: [PATCH 20/49] add artifact back to lint and test workflow --- .github/workflows/lint-and-test-matrix.yml | 9 ++++++++- .github/workflows/report-test-results.yml | 7 ------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/lint-and-test-matrix.yml b/.github/workflows/lint-and-test-matrix.yml index 66fd6b7..587b92d 100644 --- a/.github/workflows/lint-and-test-matrix.yml +++ b/.github/workflows/lint-and-test-matrix.yml @@ -63,4 +63,11 @@ jobs: run: | pip install coverage pytest pytest-cov coverage run -m pytest --maxfail=1 --disable-warnings -q - coverage report -m \ No newline at end of file + coverage report -m + coverage html + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-html-report + path: coverage_html_report \ No newline at end of file diff --git a/.github/workflows/report-test-results.yml b/.github/workflows/report-test-results.yml index bfff7a2..df8d425 100644 --- a/.github/workflows/report-test-results.yml +++ b/.github/workflows/report-test-results.yml @@ -55,10 +55,3 @@ jobs: - name: Output code coverage results to GitHub step summary run: cat code-coverage-results.md >> $GITHUB_STEP_SUMMARY - - # Optionally upload coverage reports as an artifact. - - name: Upload coverage report - uses: actions/upload-artifact@v4 - with: - name: coverage-html-report - path: coverage_html_report \ No newline at end of file From d60ba28fd1b6b24397b132e6dbc0df8cd3aa2c70 Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Fri, 20 Dec 2024 11:42:28 +0100 Subject: [PATCH 21/49] changed test report artifact name --- .github/workflows/lint-and-test-matrix.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint-and-test-matrix.yml b/.github/workflows/lint-and-test-matrix.yml index 587b92d..f3f39c8 100644 --- a/.github/workflows/lint-and-test-matrix.yml +++ b/.github/workflows/lint-and-test-matrix.yml @@ -69,5 +69,5 @@ jobs: - name: Upload coverage report uses: actions/upload-artifact@v4 with: - name: coverage-html-report + name: coverage-html-report-${{ matrix.python-version }} path: coverage_html_report \ No newline at end of file From 321be9dd4a9ec394f77ea652f01b00361a0214e8 Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Fri, 20 Dec 2024 12:55:14 +0100 Subject: [PATCH 22/49] adding the minder api from PR !64 --- PyTado/http.py | 8 ++++ PyTado/interface/api/my_tado.py | 15 ++++++ tests/fixtures/running_times.json | 80 +++++++++++++++++++++++++++++++ tests/test_my_tado.py | 13 +++++ 4 files changed, 116 insertions(+) create mode 100644 tests/fixtures/running_times.json diff --git a/PyTado/http.py b/PyTado/http.py index 486af1c..015b61e 100644 --- a/PyTado/http.py +++ b/PyTado/http.py @@ -8,6 +8,7 @@ import pprint from datetime import datetime, timedelta from typing import Any +from urllib.parse import urlencode import requests @@ -27,6 +28,7 @@ class Endpoint(enum.StrEnum): EIQ = "https://energy-insights.tado.com/api/" TARIFF = "https://tariff-experience.tado.com/api/" GENIE = "https://genie.tado.com/api/v2/" + MINDER = "https://minder.tado.com/v1/" class Domain(enum.StrEnum): @@ -65,6 +67,7 @@ def __init__( domain: Domain = Domain.HOME, device: int | None = None, mode: Mode = Mode.OBJECT, + params: dict[str, Any] | None = None, ): self.endpoint = endpoint self.command = command @@ -73,6 +76,7 @@ def __init__( self.domain = domain self.device = device self.mode = mode + self.params = params class TadoXRequest(TadoRequest): @@ -87,6 +91,7 @@ def __init__( domain: Domain = Domain.HOME, device: int | None = None, mode: Mode = Mode.OBJECT, + params: dict[str, Any] | None = None, ): super().__init__( endpoint=endpoint, @@ -96,6 +101,7 @@ def __init__( domain=domain, device=device, mode=mode, + params=params, ) self._action = action @@ -214,6 +220,8 @@ def _configure_url(self, request: TadoRequest) -> str: url = f"{request.endpoint}{request.domain}/{request.device}/{request.command}" elif request.domain == Domain.ME: url = f"{request.endpoint}{request.domain}" + elif request.endpoint == Endpoint.MINDER: + url = f"{request.endpoint}{request.domain}/{self.id:d}/{request.command}?{urlencode(request.params)}" else: url = f"{request.endpoint}{request.domain}/{self._id:d}/{request.command}" return url diff --git a/PyTado/interface/api/my_tado.py b/PyTado/interface/api/my_tado.py index 5ce3230..254612d 100644 --- a/PyTado/interface/api/my_tado.py +++ b/PyTado/interface/api/my_tado.py @@ -621,3 +621,18 @@ def set_zone_heating_circuit(self, zone, heating_circuit): request.payload = {"circuitNumber": heating_circuit} return self._http.request(request) + + def get_running_times( + self, date=datetime.datetime.now().strftime("%Y-%m-%d") + ) -> dict: + """ + Get the running times from the Minder API + """ + + request = TadoRequest() + request.command = "runningTimes" + request.action = Action.GET + request.endpoint = Endpoint.MINDER + request.params = {"from": date} + + return self._http.request(request) diff --git a/tests/fixtures/running_times.json b/tests/fixtures/running_times.json new file mode 100644 index 0000000..3a3667b --- /dev/null +++ b/tests/fixtures/running_times.json @@ -0,0 +1,80 @@ +{ + "lastUpdated": "2023-08-05T19:50:21Z", + "runningTimes": [ + { + "endTime": "2023-08-02 00:00:00", + "runningTimeInSeconds": 0, + "startTime": "2023-08-01 00:00:00", + "zones": [ + { + "id": 1, + "runningTimeInSeconds": 1 + }, + { + "id": 2, + "runningTimeInSeconds": 2 + }, + { + "id": 3, + "runningTimeInSeconds": 3 + }, + { + "id": 4, + "runningTimeInSeconds": 4 + } + ] + }, + { + "endTime": "2023-08-03 00:00:00", + "runningTimeInSeconds": 0, + "startTime": "2023-08-02 00:00:00", + "zones": [ + { + "id": 1, + "runningTimeInSeconds": 5 + }, + { + "id": 2, + "runningTimeInSeconds": 6 + }, + { + "id": 3, + "runningTimeInSeconds": 7 + }, + { + "id": 4, + "runningTimeInSeconds": 8 + } + ] + }, + { + "endTime": "2023-08-04 00:00:00", + "runningTimeInSeconds": 0, + "startTime": "2023-08-03 00:00:00", + "zones": [ + { + "id": 1, + "runningTimeInSeconds": 9 + }, + { + "id": 2, + "runningTimeInSeconds": 10 + }, + { + "id": 3, + "runningTimeInSeconds": 11 + }, + { + "id": 4, + "runningTimeInSeconds": 12 + } + ] + } + ], + "summary": { + "endTime": "2023-08-06 00:00:00", + "meanInSecondsPerDay": 24, + "startTime": "2023-08-01 00:00:00", + "totalRunningTimeInSeconds": 120 + } +} \ No newline at end of file diff --git a/tests/test_my_tado.py b/tests/test_my_tado.py index be8f046..d96ada2 100644 --- a/tests/test_my_tado.py +++ b/tests/test_my_tado.py @@ -79,3 +79,16 @@ def test_home_cant_be_set_to_auto_when_home_does_not_support_geofencing( with mock.patch("PyTado.http.Http.request"): with self.assertRaises(Exception): self.tado_client.set_auto() + + def test_get_running_times(self): + """Test the get_running_times method.""" + + with mock.patch( + "PyTado.http.Http.request", + return_value=json.loads(common.load_fixture("running_times.json")), + ): + running_times = self.tado_client.get_running_times("2023-08-01") + + assert self.tado_client._http.request.called + assert running_times["lastUpdated"] == "2023-08-05T19:50:21Z" + assert running_times["runningTimes"][0]["zones"][0]["id"] == 1 From ac455ed440dcb04a87b9a1843f83e2a5e9d7538a Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Fri, 20 Dec 2024 13:39:42 +0100 Subject: [PATCH 23/49] bumped version to 0.18.5 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6cbbad5..13f7db9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-tado" -version = "0.18.4" +version = "0.18.5" description = "PyTado from chrism0dwk, modfied by w.malgadey, diplix, michaelarnauts, LenhartStephan, splifter, syssi, andersonshatch, Yippy, p0thi, Coffee2CodeNL, chiefdragon, FilBr, nikilase, albertomontesg, Moritz-Schmidt, palazzem" authors = [ { name = "Chris Jewell", email = "chrism0dwk@gmail.com" }, From 1102bf11b21d538cca1b5156db055874a6477b1f Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Sat, 21 Dec 2024 12:29:53 +0100 Subject: [PATCH 24/49] do not break compatibility yet! (#114) --- PyTado/interface/interface.py | 259 ++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) diff --git a/PyTado/interface/interface.py b/PyTado/interface/interface.py index 348a7d0..e131420 100644 --- a/PyTado/interface/interface.py +++ b/PyTado/interface/interface.py @@ -2,10 +2,31 @@ PyTado interface abstraction to use app.tado.com or hops.tado.com """ +import datetime +import warnings +import functools + from PyTado.http import Http import PyTado.interface.api as API +def deprecated(new_func_name): + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + warnings.warn( + f"The '{func.__name__}' method is deprecated, use '{new_func_name}' instead. " + "Deprecated methods will be removed with 1.0.0.", + DeprecationWarning, + stacklevel=2, + ) + return getattr(args[0], new_func_name)(*args, **kwargs) + + return wrapper + + return decorator + + class Tado: """Interacts with a Tado thermostat via public API. @@ -37,3 +58,241 @@ def __init__( def __getattr__(self, name): """Delegiert den Aufruf von Methoden an die richtige API-Client-Implementierung.""" return getattr(self._api, name) + + # region Deprecated Methods + # pylint: disable=invalid-name + + @deprecated("get_me") + def getMe(self): + """Gets home information. (deprecated)""" + return self.get_me() + + @deprecated("get_devices") + def getDevices(self): + """Gets device information. (deprecated)""" + return self.get_devices() + + @deprecated("get_zones") + def getZones(self): + """Gets zones information. (deprecated)""" + return self.get_zones() + + @deprecated("get_zone_state") + def getZoneState(self, zone): + """Gets current state of Zone as a TadoZone object. (deprecated)""" + return self.get_zone_state(zone) + + @deprecated("get_zone_states") + def getZoneStates(self): + """Gets current states of all zones. (deprecated)""" + return self.get_zone_states() + + @deprecated("get_state") + def getState(self, zone): + """Gets current state of Zone. (deprecated)""" + return self.get_state(zone) + + @deprecated("get_home_state") + def getHomeState(self): + """Gets current state of Home. (deprecated)""" + return self.get_home_state() + + @deprecated("get_auto_geofencing_supported") + def getAutoGeofencingSupported(self): + """Return whether the Tado Home supports auto geofencing (deprecated)""" + return self.get_auto_geofencing_supported() + + @deprecated("get_capabilities") + def getCapabilities(self, zone): + """Gets current capabilities of Zone zone. (deprecated)""" + return self.get_capabilities(zone) + + @deprecated("get_climate") + def getClimate(self, zone): + """Gets temp (centigrade) and humidity (% RH) for Zone zone. (deprecated)""" + return self.get_climate(zone) + + @deprecated("get_timetable") + def getTimetable(self, zone): + """Get the Timetable type currently active (Deprecated)""" + return self.get_timetable(zone) + + @deprecated("get_historic") + def getHistoric(self, zone, date): + """Gets historic information on given date for zone. (Deprecated)""" + return self.get_historic(zone, date) + + @deprecated("set_timetable") + def setTimetable(self, zone, _id): + """Set the Timetable type currently active (Deprecated) + id = 0 : ONE_DAY (MONDAY_TO_SUNDAY) + id = 1 : THREE_DAY (MONDAY_TO_FRIDAY, SATURDAY, SUNDAY) + id = 3 : SEVEN_DAY (MONDAY, TUESDAY, WEDNESDAY ...)""" + return self.set_timetable(zone, _id) + + @deprecated("get_schedule") + def getSchedule(self, zone, _id, day=None): + """Get the JSON representation of the schedule for a zone. Zone has 3 different schedules, + one for each timetable (see setTimetable)""" + return self.get_schedule(zone, _id, day) + + @deprecated("set_schedule") + def setSchedule(self, zone, _id, day, data): + """Set the schedule for a zone, day is required""" + return self.set_schedule(zone, _id, day, data) + + @deprecated("get_weather") + def getWeather(self): + """Gets outside weather data (Deprecated)""" + return self.get_weather() + + @deprecated("get_air_comfort") + def getAirComfort(self): + """Gets air quality information (Deprecated)""" + return self.get_air_comfort() + + @deprecated("get_users") + def getAppUsers(self): + """Gets getAppUsers data (deprecated)""" + return self.get_app_user() + + @deprecated("get_mobile_devices") + def getMobileDevices(self): + """Gets information about mobile devices (Deprecated)""" + return self.get_mobile_devices() + + @deprecated("reset_zone_overlay") + def resetZoneOverlay(self, zone): + """Delete current overlay (Deprecated)""" + return self.reset_zone_overlay(zone) + + @deprecated("set_zone_overlay") + def setZoneOverlay( + self, + zone, + overlayMode, + setTemp=None, + duration=None, + deviceType="HEATING", + power="ON", + mode=None, + fanSpeed=None, + swing=None, + fanLevel=None, + verticalSwing=None, + horizontalSwing=None, + ): + """Set current overlay for a zone (Deprecated)""" + return self.set_zone_overlay( + zone, + overlay_mode=overlayMode, + set_temp=setTemp, + duration=duration, + device_type=deviceType, + power=power, + mode=mode, + fan_speed=fanSpeed, + swing=swing, + fan_level=fanLevel, + vertical_swing=verticalSwing, + horizontal_swing=horizontalSwing, + ) + + @deprecated("get_zone_overlay_default") + def getZoneOverlayDefault(self, zone): + """Get current overlay default settings for zone. (Deprecated)""" + return self.get_zone_overlay_default(zone) + + @deprecated("set_home") + def setHome(self): + """Sets HomeState to HOME (Deprecated)""" + return self.set_home() + + @deprecated("set_away") + def setAway(self): + """Sets HomeState to AWAY (Deprecated)""" + return self.set_away() + + @deprecated("change_presence") + def changePresence(self, presence): + """Sets HomeState to presence (Deprecated)""" + return self.change_presence(presence=presence) + + @deprecated("set_auto") + def setAuto(self): + """Sets HomeState to AUTO (Deprecated)""" + return self.set_auto() + + @deprecated("get_window_state") + def getWindowState(self, zone): + """Returns the state of the window for zone (Deprecated)""" + return self.get_window_state(zone=zone) + + @deprecated("get_open_window_detected") + def getOpenWindowDetected(self, zone): + """Returns whether an open window is detected. (Deprecated)""" + return self.get_open_window_detected(zone=zone) + + @deprecated("set_open_window") + def setOpenWindow(self, zone): + """Sets the window in zone to open (Deprecated)""" + return self.set_open_window(zone=zone) + + @deprecated("reset_open_window") + def resetOpenWindow(self, zone): + """Sets the window in zone to closed (Deprecated)""" + return self.reset_open_window(zone=zone) + + @deprecated("get_device_info") + def getDeviceInfo(self, device_id, cmd=""): + """Gets information about devices + with option to get specific info i.e. cmd='temperatureOffset' (Deprecated) + """ + return self.get_device_info(device_id=device_id, cmd=cmd) + + @deprecated("set_temp_offset") + def setTempOffset(self, device_id, offset=0, measure="celsius"): + """Set the Temperature offset on the device. (Deprecated)""" + return self.set_temp_offset( + device_id=device_id, offset=offset, measure=measure + ) + + @deprecated("get_eiq_tariffs") + def getEIQTariffs(self): + """Get Energy IQ tariff history (Deprecated)""" + return self.get_eiq_tariffs() + + @deprecated("get_eiq_meter_readings") + def getEIQMeterReadings(self): + """Get Energy IQ meter readings (Deprecated)""" + return self.get_eiq_meter_readings() + + @deprecated("set_eiq_meter_readings") + def setEIQMeterReadings( + self, date=datetime.datetime.now().strftime("%Y-%m-%d"), reading=0 + ): + """Send Meter Readings to Tado, date format is YYYY-MM-DD, reading is without decimals (Deprecated)""" + return self.set_eiq_meter_readings(date=date, reading=reading) + + @deprecated("set_eiq_tariff") + def setEIQTariff( + self, + from_date=datetime.datetime.now().strftime("%Y-%m-%d"), + to_date=datetime.datetime.now().strftime("%Y-%m-%d"), + tariff=0, + unit="m3", + is_period=False, + ): + """Send Tariffs to Tado, date format is YYYY-MM-DD, + tariff is with decimals, unit is either m3 or kWh, set is_period to true to set a period of price (Deprecated) + """ + return self.set_eiq_tariff( + from_date=from_date, + to_date=to_date, + tariff=tariff, + unit=unit, + is_period=is_period, + ) + + # pylint: enable=invalid-name + # endregion From f42e504a9795c829371698479d5eee4ebc8bd2bf Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Sat, 21 Dec 2024 13:11:35 +0100 Subject: [PATCH 25/49] add simple contribution guide (#109) --- CONTRIBUTION.md | 71 ++++++++++++++++++++++++++++++ README.md | 113 ++++++++++++++++++++++++++++++++++++++++-------- SECURITY.md | 13 ++++-- 3 files changed, 176 insertions(+), 21 deletions(-) create mode 100644 CONTRIBUTION.md diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md new file mode 100644 index 0000000..f4a567e --- /dev/null +++ b/CONTRIBUTION.md @@ -0,0 +1,71 @@ +# Contributing to PyTado + +Thank you for considering contributing to PyTado! This document provides guidelines to help you get started with your +contributions. Please follow the instructions below to ensure a smooth contribution process. + +1. Prepare your [development environment](https://github.com/wmalgadey/PyTado#development). +2. Ensure that you have installed the `pre-commit` hooks. + +By following these steps, you can ensure that your contributions are of the highest quality and are properly tested +before they are merged into the project. + +## Issues + +If you encounter a problem or have a suggestion, please open a [new issue](https://github.com/wmalgadey/PyTado/issues/new/choose). +Select the most appropriate type from the options provided: + +- **Bug Report**: If you've identified an issue with an existing feature that isn't performing as documented or expected, + please select this option. This will help us identify and rectify problems more efficiently. + +- **Feature Request**: If you have an idea for a new feature or an enhancement to the current ones, select this option. + Additionally, if you feel that a certain feature could be optimized or modified to better suit specific scenarios, this + is the right category to bring it to our attention. + +- **General Question**: If you are unsure or have a general question, please join our + [GitHub Discussions](https://github.com/wmalgadey/PyTado/discussions). + +After choosing an issue type, a pre-formatted template will appear. Provide as much detail as possible within this +template. Your insights and contributions help improve the project, and we genuinely appreciate your effort. + +## Pull Requests + +### PR Title + +We follow the [conventional commit convention](https://www.conventionalcommits.org/en/v1.0.0/) for our PR titles. The +title should adhere to the structure below: + +```text +[optional scope]: +``` + +The common types are: + +- `feat` (enhancements) +- `fix` (bug fixes) +- `docs` (documentation changes) +- `perf` (performance improvements) +- `refactor` (major code refactorings) +- `tests` (changes to tests) +- `tools` (changes to package spec or tools in general) +- `ci` (changes to our CI) +- `deps` (changes to dependencies) + +If your change breaks backwards compatibility, indicate so by adding `!` after the type. + +Examples: + +- `feat(cli): add Transcribe command` +- `fix: ensure hashing function returns correct value for random input` +- `feat!: remove deprecated API` (a change that breaks backwards compatibility) + +### PR Description + +After opening a new pull request, a pre-formatted template will appear. Provide as much detail as possible within this +template. A good description can speed up the review process to get your code merged. + +## Code of Conduct + +Please note that this project is released with a [Contributor Code of Conduct](https://www.contributor-covenant.org/version/2/0/code_of_conduct/). +By participating in this project, you agree to abide by its terms. + +Thank you for your contributions! diff --git a/README.md b/README.md index c427647..9e0a3d2 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,111 @@ -PyTado -- Pythonize your central heating -======================================== +# PyTado -- Pythonize your central heating -Author: Chris Jewell -Modified: Wolfgang Malgadey +[![Linting and Testing](https://github.com/wmalgadey/PyTado/actions/workflows/lint-and-test-matrix.yaml/badge.svg)](https://github.com/wmalgadey/PyTado/actions/workflows/lint-and-test-matrix.yaml) +[![Building](https://github.com/wmalgadey/PyTado/actions/workflows/publish-to-pypi.yaml/badge.svg)](https://github.com/wmalgadey/PyTado/actions/workflows/publish-to-pypi.yaml) +[![PyPI version](https://badge.fury.io/py/python-tado.svg)](https://badge.fury.io/py/python-tado) + +PyTado is a Python module implementing an interface to the Tado web API. It allows a user to interact with their +Tado heating system for the purposes of monitoring or controlling their heating system, beyond what Tado themselves +currently offer. + +It is hoped that this module might be used by those who wish to tweak their Tado systems, and further optimise their +heating setups. + +--- + +Original author: Chris Jewell Licence: GPL v3 Copyright: Chris Jewell 2016-2018 -PyTado is a Python module implementing an interface to the Tado web API. It allows a user to interact with their Tado heating system for the purposes of monitoring or controlling their heating system, beyond what Tado themselves currently offer. +## Disclaimer -It is hoped that this module might be used by those who wish to tweak their Tado systems, and further optimise their heating setups. +Besides owning a Tado system, I have no connection with the Tado company themselves. PyTado was created for my own use, +and for others who may wish to experiment with personal Internet of Things systems. I receive no help (financial or +otherwise) from Tado, and have no business interest with them. This software is provided without warranty, according to +the GNU Public Licence version 3, and should therefore not be used where it may endanger life, financial stakes, or +cause discomfort and inconvenience to others. -Disclaimer ----------- -Besides owning a Tado system, I have no connection with the Tado company themselves. PyTado was created for my own use, and for others who may wish to experiment with personal Internet of Things systems. I receive no help (financial or otherwise) from Tado, and have no business interest with them. This software is provided without warranty, according to the GNU Public Licence version 3, and should therefore not be used where it may endanger life, financial stakes, or cause discomfort and inconvenience to others. - -Example basic usage -------------------- +## Example basic usage >>> from PyTado.interface import Tado >>> t = Tado('my@username.com', 'mypassword') >>> climate = t.get_climate(zone=1) -Development ------------ -This software is at a purely experimental stage. If you're interested and can write Python, clone the Github repo, drop me a line, and get involved! +## Contributing + +We are very open to the community's contributions - be it a quick fix of a typo, or a completely new feature! + +You don't need to be a Python expert to provide meaningful improvements. To learn how to get started, check out our +[Contributor Guidelines](https://github.com/wmalgadey/econnect-python/blob/main/CONTRIBUTING.md) first, and ask for help +in [GitHub Discussions](https://github.com/wmalgadey/PyTado/discussions) if you have questions. + +## Development + +We welcome external contributions, even though the project was initially intended for personal use. If you think some +parts could be exposed with a more generic interface, please open a [GitHub issue](https://github.com/wmalgadey/PyTado/issues) +to discuss your suggestion. + +### Dev Environment + +To contribute to this repository, you should first clone your fork and then setup your development environment. Clone +your repository as follows (replace yourusername with your GitHub account name): + +```bash +git clone https://github.com/yourusername/PyTado.git +cd PyTado +``` + +Then, to create your development environment and install the project with its dependencies, execute the following +commands in your terminal: + +```bash +# Create and activate a new virtual environment +python3 -m venv venv +source venv/bin/activate + +# Upgrade pip and install all projects and their dependencies +pip install --upgrade pip +pip install -e '.[all]' + +# Install pre-commit hooks +pre-commit install +``` + +### Coding Guidelines + +To maintain a consistent codebase, we utilize [black][1]. Consistency is crucial as it helps readability, reduces errors, +and facilitates collaboration among developers. + +To ensure that every commit adheres to our coding standards, we've integrated [pre-commit hooks][2]. These hooks +automatically run `black` before each commit, ensuring that all code changes are automatically checked and formatted. + +For details on how to set up your development environment to make use of these hooks, please refer to the +[Development][3] section of our documentation. + +[1]: https://github.com/ambv/black +[2]: https://pre-commit.com/ +[3]: https://github.com/wmalgadey/PyTado#development + +### Testing + +Ensuring the robustness and reliability of our code is paramount. Therefore, all contributions must include at least one +test to verify the intended behavior. + +To run tests locally, execute the test suite using `pytest` with the following command: + +```bash +pytest tests/ --cov --cov-branch -vv +``` + +--- -Best wishes and a warm winter to all! +A message from the original author: -Chris Jewell +> This software is at a purely experimental stage. If you're interested and can write Python, clone the Github repo, +> drop me a line, and get involved! +> +> Best wishes and a warm winter to all! +> +> Chris Jewell diff --git a/SECURITY.md b/SECURITY.md index f7cbb88..7b67146 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,8 @@ ## Supported Versions -We take security vulnerabilities seriously. The following table outlines the versions of this project currently supported with security updates: +We take security vulnerabilities seriously. The following table outlines the versions of this project currently +supported with security updates: | Version | Supported | |---------|--------------------| @@ -11,7 +12,8 @@ We take security vulnerabilities seriously. The following table outlines the ver ## Reporting a Vulnerability -If you discover a security vulnerability, please contact us as soon as possible. We appreciate your efforts in responsibly disclosing vulnerabilities to help keep the project and its users safe. +If you discover a security vulnerability, please contact us as soon as possible. We appreciate your efforts in +responsibly disclosing vulnerabilities to help keep the project and its users safe. ### How to Report @@ -27,7 +29,9 @@ We kindly ask that you do **not** disclose the vulnerability publicly until we h ## Response Time -We will acknowledge receipt of your report within **72 hours**. After our initial assessment, we will provide updates on remediation progress as we work toward releasing a fix. We aim to issue a patch or provide a mitigation strategy within **14 days** of confirming a legitimate vulnerability. +We will acknowledge receipt of your report within **72 hours**. After our initial assessment, we will provide updates +on remediation progress as we work toward releasing a fix. We aim to issue a patch or provide a mitigation strategy +within **14 days** of confirming a legitimate vulnerability. ## Disclosure Policy @@ -39,4 +43,5 @@ Once the vulnerability has been resolved, and a patch or mitigation has been mad ## Thank You -Your efforts to secure this project are greatly appreciated. Thank you for helping us maintain a safe and reliable environment for our users. +Your efforts to secure this project are greatly appreciated. Thank you for helping us maintain a safe and reliable +environment for our users. From 5a9e5d3e1bc489d9fb31556c33ffa04bf8480622 Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Sat, 21 Dec 2024 13:24:07 +0100 Subject: [PATCH 26/49] added pre commit features (#110) --- .github/workflows/lint-and-test-matrix.yml | 6 +-- .github/workflows/report-test-results.yml | 3 +- .pre-commit-config.yaml | 62 ++++++++++++++++++++++ pyproject.toml | 6 ++- requirements.txt | 2 - 5 files changed, 70 insertions(+), 9 deletions(-) create mode 100644 .pre-commit-config.yaml delete mode 100644 requirements.txt diff --git a/.github/workflows/lint-and-test-matrix.yml b/.github/workflows/lint-and-test-matrix.yml index f3f39c8..9fd3bfa 100644 --- a/.github/workflows/lint-and-test-matrix.yml +++ b/.github/workflows/lint-and-test-matrix.yml @@ -28,8 +28,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt - pip install -e . + pip install -e '.[all]' - name: Lint with black uses: psf/black@stable @@ -56,8 +55,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt - pip install -e . + pip install -e '.[all]' - name: Run Tests with Coverage run: | diff --git a/.github/workflows/report-test-results.yml b/.github/workflows/report-test-results.yml index df8d425..b4db57a 100644 --- a/.github/workflows/report-test-results.yml +++ b/.github/workflows/report-test-results.yml @@ -22,8 +22,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt - pip install -e . + pip install -e '.[all]' - name: Run Tests with Coverage run: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..98e5388 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,62 @@ +fail_fast: true + +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-ast + - id: check-json + - id: check-merge-conflict + - id: check-toml + - id: check-yaml + - id: end-of-file-fixer + exclude: custom_components/econnect_metronet/manifest.json + - id: mixed-line-ending + - id: trailing-whitespace + +- repo: https://github.com/PyCQA/isort + rev: 5.13.2 + hooks: + - id: isort + exclude: tests/ + args: ["--profile", "black"] + +- repo: https://github.com/asottile/pyupgrade + rev: v3.19.1 + hooks: + - id: pyupgrade + +- repo: https://github.com/psf/black + rev: 24.10.0 + hooks: + - id: black + exclude: tests/ + args: [--line-length=120] + +- repo: https://github.com/PyCQA/flake8 + rev: 7.1.1 + hooks: + - id: flake8 + exclude: tests/ + args: [--max-line-length=120 ] + +- repo: https://github.com/PyCQA/bandit + rev: '1.8.0' + hooks: + - id: bandit + exclude: tests/ + +- repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.8.4 + hooks: + - id: ruff + exclude: tests/ + args: [--line-length=120] + +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.13.0 + hooks: + - id: mypy + exclude: tests/ + additional_dependencies: [types-requests] \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 13f7db9..bc53d27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,11 @@ classifiers = [ GitHub = "https://github.com/wmalgadey/PyTado" [project.optional-dependencies] -dev = ["black>=24.3", "pytype", "pylint", "types-requests", "responses", "coverage", "pytest", "pytest-cov"] +dev = ["black>=24.3", "pre-commit", "pytype", "pylint", "types-requests", "requests", "responses", "coverage", "pytest", "pytest-cov"] + +all = [ + "python-tado[dev]", +] [project.scripts] pytado = "pytado.__main__:main" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 76c28c5..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -requests -responses From 91121d2d70cd74ad925f5d2dd26918bb50d40eee Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Sat, 21 Dec 2024 13:33:36 +0100 Subject: [PATCH 27/49] fixed status badges --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9e0a3d2..9aca32f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # PyTado -- Pythonize your central heating -[![Linting and Testing](https://github.com/wmalgadey/PyTado/actions/workflows/lint-and-test-matrix.yaml/badge.svg)](https://github.com/wmalgadey/PyTado/actions/workflows/lint-and-test-matrix.yaml) -[![Building](https://github.com/wmalgadey/PyTado/actions/workflows/publish-to-pypi.yaml/badge.svg)](https://github.com/wmalgadey/PyTado/actions/workflows/publish-to-pypi.yaml) +[![Linting and testing](https://github.com/wmalgadey/PyTado/actions/workflows/lint-and-test-matrix.yml/badge.svg)](https://github.com/wmalgadey/PyTado/actions/workflows/lint-and-test-matrix.yml) +[![Build and deploy to pypi](https://github.com/wmalgadey/PyTado/actions/workflows/publish-to-pypi.yml/badge.svg)](https://github.com/wmalgadey/PyTado/actions/workflows/publish-to-pypi.yml) [![PyPI version](https://badge.fury.io/py/python-tado.svg)](https://badge.fury.io/py/python-tado) PyTado is a Python module implementing an interface to the Tado web API. It allows a user to interact with their From e5dd148595cb4638e41ea7c1cca26ab7840e39a1 Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Sat, 21 Dec 2024 13:45:37 +0100 Subject: [PATCH 28/49] changed status badge for release --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9aca32f..fd66dad 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # PyTado -- Pythonize your central heating [![Linting and testing](https://github.com/wmalgadey/PyTado/actions/workflows/lint-and-test-matrix.yml/badge.svg)](https://github.com/wmalgadey/PyTado/actions/workflows/lint-and-test-matrix.yml) -[![Build and deploy to pypi](https://github.com/wmalgadey/PyTado/actions/workflows/publish-to-pypi.yml/badge.svg)](https://github.com/wmalgadey/PyTado/actions/workflows/publish-to-pypi.yml) +[![Build and deploy to pypi](https://github.com/wmalgadey/PyTado/actions/workflows/publish-to-pypi.yml/badge.svg?event=release)](https://github.com/wmalgadey/PyTado/actions/workflows/publish-to-pypi.yml) [![PyPI version](https://badge.fury.io/py/python-tado.svg)](https://badge.fury.io/py/python-tado) PyTado is a Python module implementing an interface to the Tado web API. It allows a user to interact with their From ffb36d123665e6cc5dd9cb23b30449200a0c80fb Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Sat, 21 Dec 2024 13:53:54 +0100 Subject: [PATCH 29/49] autofixed pre-commit changes --- .github/workflows/lint-and-test-matrix.yml | 8 ++++---- .gitignore | 2 +- .pre-commit-config.yaml | 2 +- .vscode/extensions.json | 2 +- .vscode/settings.json | 2 +- tests/fixtures/ac_issue_32294.heat_mode.json | 2 +- tests/fixtures/home_1234/my_api_v2_me.json | 2 +- tests/fixtures/home_1234/tadov2.my_api_v2_home_state.json | 2 +- tests/fixtures/home_1234/tadox.heating.auto_mode.json | 2 +- tests/fixtures/home_1234/tadox.heating.manual_mode.json | 2 +- tests/fixtures/home_1234/tadox.heating.manual_off.json | 2 +- tests/fixtures/home_1234/tadox.hops_homes.json | 2 +- tests/fixtures/home_1234/tadox.my_api_v2_home_state.json | 2 +- tests/fixtures/my_api_issue_88.termination_condition.json | 2 +- tests/fixtures/running_times.json | 2 +- tests/fixtures/smartac3.auto_mode.json | 2 +- tests/fixtures/smartac3.cool_mode.json | 2 +- tests/fixtures/smartac3.dry_mode.json | 2 +- tests/fixtures/smartac3.fan_mode.json | 2 +- tests/fixtures/smartac3.heat_mode.json | 2 +- tests/fixtures/smartac3.manual_off.json | 2 +- tests/fixtures/smartac3.smart_mode.json | 2 +- tests/fixtures/tadov2.heating.off_mode.json | 2 +- tests/fixtures/tadov2.home_state.auto_not_supported.json | 4 ++-- .../tadov2.home_state.auto_supported.auto_mode.json | 4 ++-- .../tadov2.home_state.auto_supported.manual_mode.json | 6 +++--- tests/fixtures/tadox/hops_tado_homes_features.json | 2 +- .../hops_tado_homes_programmer_domesticHotWater.json | 2 +- ...hops_tado_homes_quickActions_boost_boostableZones.json | 2 +- 29 files changed, 36 insertions(+), 36 deletions(-) diff --git a/.github/workflows/lint-and-test-matrix.yml b/.github/workflows/lint-and-test-matrix.yml index 9fd3bfa..c333899 100644 --- a/.github/workflows/lint-and-test-matrix.yml +++ b/.github/workflows/lint-and-test-matrix.yml @@ -24,12 +24,12 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - + - name: Install dependencies run: | python -m pip install --upgrade pip pip install -e '.[all]' - + - name: Lint with black uses: psf/black@stable with: @@ -51,7 +51,7 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - + - name: Install dependencies run: | python -m pip install --upgrade pip @@ -68,4 +68,4 @@ jobs: uses: actions/upload-artifact@v4 with: name: coverage-html-report-${{ matrix.python-version }} - path: coverage_html_report \ No newline at end of file + path: coverage_html_report diff --git a/.gitignore b/.gitignore index 6769e21..68bc17f 100644 --- a/.gitignore +++ b/.gitignore @@ -157,4 +157,4 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ \ No newline at end of file +#.idea/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 98e5388..59469b5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -59,4 +59,4 @@ repos: hooks: - id: mypy exclude: tests/ - additional_dependencies: [types-requests] \ No newline at end of file + additional_dependencies: [types-requests] diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 86bda98..8cf2696 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -4,4 +4,4 @@ "github.vscode-github-actions", "ms-python.pylint" ] -} \ No newline at end of file +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 1518843..8729a15 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,4 +9,4 @@ "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, "ansible.python.interpreterPath": "/Volumes/Daten/Projects/PyTado/.venv/bin/python" -} \ No newline at end of file +} diff --git a/tests/fixtures/ac_issue_32294.heat_mode.json b/tests/fixtures/ac_issue_32294.heat_mode.json index 098afd0..b4e7c70 100644 --- a/tests/fixtures/ac_issue_32294.heat_mode.json +++ b/tests/fixtures/ac_issue_32294.heat_mode.json @@ -57,4 +57,4 @@ "celsius": 25.0 } } -} \ No newline at end of file +} diff --git a/tests/fixtures/home_1234/my_api_v2_me.json b/tests/fixtures/home_1234/my_api_v2_me.json index c2e4a92..0beb589 100644 --- a/tests/fixtures/home_1234/my_api_v2_me.json +++ b/tests/fixtures/home_1234/my_api_v2_me.json @@ -73,4 +73,4 @@ } } ] -} \ No newline at end of file +} diff --git a/tests/fixtures/home_1234/tadov2.my_api_v2_home_state.json b/tests/fixtures/home_1234/tadov2.my_api_v2_home_state.json index fe6bf63..a20d13d 100644 --- a/tests/fixtures/home_1234/tadov2.my_api_v2_home_state.json +++ b/tests/fixtures/home_1234/tadov2.my_api_v2_home_state.json @@ -42,4 +42,4 @@ "isEnergyIqEligible": true, "isHeatSourceInstalled": false, "isHeatPumpInstalled": false -} \ No newline at end of file +} diff --git a/tests/fixtures/home_1234/tadox.heating.auto_mode.json b/tests/fixtures/home_1234/tadox.heating.auto_mode.json index b5bb1ac..fc40890 100644 --- a/tests/fixtures/home_1234/tadox.heating.auto_mode.json +++ b/tests/fixtures/home_1234/tadox.heating.auto_mode.json @@ -37,4 +37,4 @@ "start": "2024-12-19T21:00:00Z" }, "balanceControl": null -} \ No newline at end of file +} diff --git a/tests/fixtures/home_1234/tadox.heating.manual_mode.json b/tests/fixtures/home_1234/tadox.heating.manual_mode.json index be91f9c..ce52812 100644 --- a/tests/fixtures/home_1234/tadox.heating.manual_mode.json +++ b/tests/fixtures/home_1234/tadox.heating.manual_mode.json @@ -41,4 +41,4 @@ "start": "2024-12-19T21:00:00Z" }, "balanceControl": null -} \ No newline at end of file +} diff --git a/tests/fixtures/home_1234/tadox.heating.manual_off.json b/tests/fixtures/home_1234/tadox.heating.manual_off.json index 2eecb6b..3e82d43 100644 --- a/tests/fixtures/home_1234/tadox.heating.manual_off.json +++ b/tests/fixtures/home_1234/tadox.heating.manual_off.json @@ -39,4 +39,4 @@ "start": "2024-12-19T21:00:00Z" }, "balanceControl": null -} \ No newline at end of file +} diff --git a/tests/fixtures/home_1234/tadox.hops_homes.json b/tests/fixtures/home_1234/tadox.hops_homes.json index b236fb6..b738639 100644 --- a/tests/fixtures/home_1234/tadox.hops_homes.json +++ b/tests/fixtures/home_1234/tadox.hops_homes.json @@ -3,4 +3,4 @@ "isHeatSourceInstalled": false, "isHeatPumpInstalled": false, "supportsFlowTemperatureOptimization": false -} \ No newline at end of file +} diff --git a/tests/fixtures/home_1234/tadox.my_api_v2_home_state.json b/tests/fixtures/home_1234/tadox.my_api_v2_home_state.json index 0cebbb0..1bbab80 100644 --- a/tests/fixtures/home_1234/tadox.my_api_v2_home_state.json +++ b/tests/fixtures/home_1234/tadox.my_api_v2_home_state.json @@ -43,4 +43,4 @@ "isEnergyIqEligible": true, "isHeatSourceInstalled": false, "isHeatPumpInstalled": false -} \ No newline at end of file +} diff --git a/tests/fixtures/my_api_issue_88.termination_condition.json b/tests/fixtures/my_api_issue_88.termination_condition.json index 5715b60..d07ef8b 100644 --- a/tests/fixtures/my_api_issue_88.termination_condition.json +++ b/tests/fixtures/my_api_issue_88.termination_condition.json @@ -77,4 +77,4 @@ "type": "TIMER", "durationInSeconds": 1300 } -} \ No newline at end of file +} diff --git a/tests/fixtures/running_times.json b/tests/fixtures/running_times.json index 3a3667b..99443fc 100644 --- a/tests/fixtures/running_times.json +++ b/tests/fixtures/running_times.json @@ -77,4 +77,4 @@ "startTime": "2023-08-01 00:00:00", "totalRunningTimeInSeconds": 120 } -} \ No newline at end of file +} diff --git a/tests/fixtures/smartac3.auto_mode.json b/tests/fixtures/smartac3.auto_mode.json index 254b409..f92fd3f 100644 --- a/tests/fixtures/smartac3.auto_mode.json +++ b/tests/fixtures/smartac3.auto_mode.json @@ -54,4 +54,4 @@ "mode": "AUTO", "power": "ON" } -} \ No newline at end of file +} diff --git a/tests/fixtures/smartac3.cool_mode.json b/tests/fixtures/smartac3.cool_mode.json index a7db2cc..b175046 100644 --- a/tests/fixtures/smartac3.cool_mode.json +++ b/tests/fixtures/smartac3.cool_mode.json @@ -64,4 +64,4 @@ "celsius": 17.78 } } -} \ No newline at end of file +} diff --git a/tests/fixtures/smartac3.dry_mode.json b/tests/fixtures/smartac3.dry_mode.json index d04612d..1dcf9d5 100644 --- a/tests/fixtures/smartac3.dry_mode.json +++ b/tests/fixtures/smartac3.dry_mode.json @@ -54,4 +54,4 @@ "mode": "DRY", "power": "ON" } -} \ No newline at end of file +} diff --git a/tests/fixtures/smartac3.fan_mode.json b/tests/fixtures/smartac3.fan_mode.json index 6907c31..7c75d8d 100644 --- a/tests/fixtures/smartac3.fan_mode.json +++ b/tests/fixtures/smartac3.fan_mode.json @@ -54,4 +54,4 @@ "mode": "FAN", "power": "ON" } -} \ No newline at end of file +} diff --git a/tests/fixtures/smartac3.heat_mode.json b/tests/fixtures/smartac3.heat_mode.json index 06b5a35..b38f66c 100644 --- a/tests/fixtures/smartac3.heat_mode.json +++ b/tests/fixtures/smartac3.heat_mode.json @@ -64,4 +64,4 @@ "celsius": 16.11 } } -} \ No newline at end of file +} diff --git a/tests/fixtures/smartac3.manual_off.json b/tests/fixtures/smartac3.manual_off.json index a9538f3..015651e 100644 --- a/tests/fixtures/smartac3.manual_off.json +++ b/tests/fixtures/smartac3.manual_off.json @@ -52,4 +52,4 @@ "type": "AIR_CONDITIONING", "power": "OFF" } -} \ No newline at end of file +} diff --git a/tests/fixtures/smartac3.smart_mode.json b/tests/fixtures/smartac3.smart_mode.json index 357a1a9..0df9d38 100644 --- a/tests/fixtures/smartac3.smart_mode.json +++ b/tests/fixtures/smartac3.smart_mode.json @@ -47,4 +47,4 @@ "celsius": 20.0 } } -} \ No newline at end of file +} diff --git a/tests/fixtures/tadov2.heating.off_mode.json b/tests/fixtures/tadov2.heating.off_mode.json index e22805a..6d902bb 100644 --- a/tests/fixtures/tadov2.heating.off_mode.json +++ b/tests/fixtures/tadov2.heating.off_mode.json @@ -64,4 +64,4 @@ "timestamp": "2020-03-10T07:44:11.947Z" } } -} \ No newline at end of file +} diff --git a/tests/fixtures/tadov2.home_state.auto_not_supported.json b/tests/fixtures/tadov2.home_state.auto_not_supported.json index 32228cc..2d57a56 100644 --- a/tests/fixtures/tadov2.home_state.auto_not_supported.json +++ b/tests/fixtures/tadov2.home_state.auto_not_supported.json @@ -1,4 +1,4 @@ { - "presence": "HOME", + "presence": "HOME", "presenceLocked": true - } \ No newline at end of file + } diff --git a/tests/fixtures/tadov2.home_state.auto_supported.auto_mode.json b/tests/fixtures/tadov2.home_state.auto_supported.auto_mode.json index de02c9b..f7b9618 100644 --- a/tests/fixtures/tadov2.home_state.auto_supported.auto_mode.json +++ b/tests/fixtures/tadov2.home_state.auto_supported.auto_mode.json @@ -1,4 +1,4 @@ { - "presence": "HOME", + "presence": "HOME", "presenceLocked": false - } \ No newline at end of file + } diff --git a/tests/fixtures/tadov2.home_state.auto_supported.manual_mode.json b/tests/fixtures/tadov2.home_state.auto_supported.manual_mode.json index 62b5d87..4b43836 100644 --- a/tests/fixtures/tadov2.home_state.auto_supported.manual_mode.json +++ b/tests/fixtures/tadov2.home_state.auto_supported.manual_mode.json @@ -1,5 +1,5 @@ { - "presence": "HOME", - "presenceLocked": true, + "presence": "HOME", + "presenceLocked": true, "showSwitchToAutoGeofencingButton": true -} \ No newline at end of file +} diff --git a/tests/fixtures/tadox/hops_tado_homes_features.json b/tests/fixtures/tadox/hops_tado_homes_features.json index 61d7af8..93d7fc7 100644 --- a/tests/fixtures/tadox/hops_tado_homes_features.json +++ b/tests/fixtures/tadox/hops_tado_homes_features.json @@ -3,4 +3,4 @@ "geofencing", "openWindowDetection" ] -} \ No newline at end of file +} diff --git a/tests/fixtures/tadox/hops_tado_homes_programmer_domesticHotWater.json b/tests/fixtures/tadox/hops_tado_homes_programmer_domesticHotWater.json index 5a91ea6..48744c7 100644 --- a/tests/fixtures/tadox/hops_tado_homes_programmer_domesticHotWater.json +++ b/tests/fixtures/tadox/hops_tado_homes_programmer_domesticHotWater.json @@ -1,4 +1,4 @@ { "isDomesticHotWaterCapable": false, "domesticHotWaterInterface": "NONE" -} \ No newline at end of file +} diff --git a/tests/fixtures/tadox/hops_tado_homes_quickActions_boost_boostableZones.json b/tests/fixtures/tadox/hops_tado_homes_quickActions_boost_boostableZones.json index 54ac8e1..bc9a3ec 100644 --- a/tests/fixtures/tadox/hops_tado_homes_quickActions_boost_boostableZones.json +++ b/tests/fixtures/tadox/hops_tado_homes_quickActions_boost_boostableZones.json @@ -1,3 +1,3 @@ { "zones": [] -} \ No newline at end of file +} From d9588fe2eba4f646316a590c8a9417139b9730b6 Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Wed, 25 Dec 2024 10:18:13 +0100 Subject: [PATCH 30/49] changed pre-commit settings for line-length --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 59469b5..5a18873 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,14 +31,14 @@ repos: hooks: - id: black exclude: tests/ - args: [--line-length=120] + args: [--line-length=80] - repo: https://github.com/PyCQA/flake8 rev: 7.1.1 hooks: - id: flake8 exclude: tests/ - args: [--max-line-length=120 ] + args: [--max-line-length=80] - repo: https://github.com/PyCQA/bandit rev: '1.8.0' @@ -52,7 +52,7 @@ repos: hooks: - id: ruff exclude: tests/ - args: [--line-length=120] + args: [--line-length=80] - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.13.0 From 9403e6039ae91011f8b9e97f8c4a7b32887e2d4c Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Wed, 25 Dec 2024 10:23:16 +0100 Subject: [PATCH 31/49] changed permission settings to report test results --- .github/workflows/report-test-results.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/report-test-results.yml b/.github/workflows/report-test-results.yml index b4db57a..5096e1e 100644 --- a/.github/workflows/report-test-results.yml +++ b/.github/workflows/report-test-results.yml @@ -1,5 +1,8 @@ name: Output test results +permissions: + pull-requests: write + on: push: branches: [ master ] @@ -18,12 +21,12 @@ jobs: uses: actions/setup-python@v5 with: python-version: 3.12 - + - name: Install dependencies run: | python -m pip install --upgrade pip pip install -e '.[all]' - + - name: Run Tests with Coverage run: | pip install coverage pytest pytest-cov From a31d9e907f1192dd78d76363d7b7372a826c8a67 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 25 Dec 2024 10:26:06 +0100 Subject: [PATCH 32/49] Add Devcontainer (#119) --- .devcontainer/devcontainer.json | 62 +++++++++++++++++++++++++++++++++ README.md | 14 +++++++- 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 .devcontainer/devcontainer.json diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..9b24292 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,62 @@ +{ + "customizations": { + "codespaces": { + "openFiles": [ + "README.md", + "CONTRIBUTING.md" + ] + }, + "vscode": { + "extensions": [ + "ms-python.python", + "redhat.vscode-yaml", + "esbenp.prettier-vscode", + "GitHub.vscode-pull-request-github", + "charliermarsh.ruff", + "GitHub.vscode-github-actions", + "ryanluker.vscode-coverage-gutters" + ], + "settings": { + "[python]": { + "editor.codeActionsOnSave": { + "source.fixAll": true, + "source.organizeImports": true + } + }, + "coverage-gutters.customizable.context-menu": true, + "coverage-gutters.customizable.status-bar-toggler-watchCoverageAndVisibleEditors-enabled": true, + "coverage-gutters.showGutterCoverage": false, + "coverage-gutters.showLineCoverage": true, + "coverage-gutters.xmlname": "coverage.xml", + "python.analysis.extraPaths": [ + "${workspaceFolder}/src" + ], + "python.defaultInterpreterPath": ".venv/bin/python", + "python.formatting.provider": "black", + "python.linting.enabled": true, + "python.linting.mypyEnabled": true, + "python.linting.pylintEnabled": true, + "python.testing.cwd": "${workspaceFolder}", + "python.testing.pytestArgs": [ + "--cov-report=xml" + ], + "python.testing.pytestEnabled": true, + "ruff.importStrategy": "fromEnvironment", + "ruff.interpreter": [ + ".venv/bin/python" + ], + "terminal.integrated.defaultProfile.linux": "zsh" + } + } + }, + "features": { + "ghcr.io/devcontainers-contrib/features/poetry:2": {}, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/node:1": {}, + "ghcr.io/devcontainers/features/python:1": { + "installTools": false + } + }, + "image": "mcr.microsoft.com/vscode/devcontainers/python:3.12", + "name": "PyTado" +} \ No newline at end of file diff --git a/README.md b/README.md index fd66dad..2ef025a 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![Linting and testing](https://github.com/wmalgadey/PyTado/actions/workflows/lint-and-test-matrix.yml/badge.svg)](https://github.com/wmalgadey/PyTado/actions/workflows/lint-and-test-matrix.yml) [![Build and deploy to pypi](https://github.com/wmalgadey/PyTado/actions/workflows/publish-to-pypi.yml/badge.svg?event=release)](https://github.com/wmalgadey/PyTado/actions/workflows/publish-to-pypi.yml) [![PyPI version](https://badge.fury.io/py/python-tado.svg)](https://badge.fury.io/py/python-tado) +[![Open in Dev Containers][devcontainer-shield]][devcontainer] PyTado is a Python module implementing an interface to the Tado web API. It allows a user to interact with their Tado heating system for the purposes of monitoring or controlling their heating system, beyond what Tado themselves @@ -13,7 +14,7 @@ heating setups. --- -Original author: Chris Jewell +Original author: Chris Jewell Licence: GPL v3 @@ -47,6 +48,13 @@ We welcome external contributions, even though the project was initially intende parts could be exposed with a more generic interface, please open a [GitHub issue](https://github.com/wmalgadey/PyTado/issues) to discuss your suggestion. +### Setting up a devcontainer + +The easiest way to start, is by opening a CodeSpace here on GitHub, or by using +the [Dev Container][devcontainer] feature of Visual Studio Code. + +[![Open in Dev Containers][devcontainer-shield]][devcontainer] + ### Dev Environment To contribute to this repository, you should first clone your fork and then setup your development environment. Clone @@ -109,3 +117,7 @@ A message from the original author: > Best wishes and a warm winter to all! > > Chris Jewell + + +[devcontainer-shield]: https://img.shields.io/static/v1?label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode +[devcontainer]: https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/wmalgadey/PyTado From 058ccd5d44e51a7ecddac31017c392e83a28abbf Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Wed, 25 Dec 2024 10:26:18 +0100 Subject: [PATCH 33/49] whitespace removed --- CONTRIBUTION.md | 2 +- SECURITY.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md index f4a567e..9e61de4 100644 --- a/CONTRIBUTION.md +++ b/CONTRIBUTION.md @@ -16,7 +16,7 @@ Select the most appropriate type from the options provided: - **Bug Report**: If you've identified an issue with an existing feature that isn't performing as documented or expected, please select this option. This will help us identify and rectify problems more efficiently. - + - **Feature Request**: If you have an idea for a new feature or an enhancement to the current ones, select this option. Additionally, if you feel that a certain feature could be optimized or modified to better suit specific scenarios, this is the right category to bring it to our attention. diff --git a/SECURITY.md b/SECURITY.md index 7b67146..68743d1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -18,7 +18,7 @@ responsibly disclosing vulnerabilities to help keep the project and its users sa ### How to Report - **Email:** [wolfgang@malgadey.de](mailto:wolfgang@malgadey.de) - + Please include the following details to help us address the issue promptly: - A clear description of the vulnerability and its potential impact. From 5a12dba9b972e2d3ce37b2b73c9424b9153705cb Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Wed, 25 Dec 2024 12:52:56 +0100 Subject: [PATCH 34/49] fixed all pre commit errors (#121) --- .devcontainer/devcontainer.json | 2 +- .pre-commit-config.yaml | 29 +++++++- PyTado/__main__.py | 29 ++------ PyTado/const.py | 12 +--- PyTado/http.py | 54 +++++++------- PyTado/interface/__init__.py | 2 + PyTado/interface/api/__init__.py | 2 + PyTado/interface/api/hops_tado.py | 42 +++-------- PyTado/interface/api/my_tado.py | 58 ++++----------- PyTado/interface/interface.py | 25 +++---- PyTado/logger.py | 8 +-- PyTado/zone/__init__.py | 2 + PyTado/zone/hops_zone.py | 51 +++++-------- PyTado/zone/my_zone.py | 115 +++++++++--------------------- pyproject.toml | 9 ++- tests/common.py | 2 +- 16 files changed, 170 insertions(+), 272 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 9b24292..7d9b3ad 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -59,4 +59,4 @@ }, "image": "mcr.microsoft.com/vscode/devcontainers/python:3.12", "name": "PyTado" -} \ No newline at end of file +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5a18873..8ff4cd9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,16 +4,21 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: + - id: no-commit-to-branch + name: "Don't commit to master branch" + args: [--branch, master] - id: check-ast - id: check-json - id: check-merge-conflict - id: check-toml - id: check-yaml + - id: check-json - id: end-of-file-fixer exclude: custom_components/econnect_metronet/manifest.json - id: mixed-line-ending - id: trailing-whitespace + - repo: https://github.com/PyCQA/isort rev: 5.13.2 hooks: @@ -31,20 +36,21 @@ repos: hooks: - id: black exclude: tests/ - args: [--line-length=80] + args: [--line-length=100] - repo: https://github.com/PyCQA/flake8 rev: 7.1.1 hooks: - id: flake8 exclude: tests/ - args: [--max-line-length=80] + args: [--max-line-length=100 ] - repo: https://github.com/PyCQA/bandit rev: '1.8.0' hooks: - id: bandit exclude: tests/ + args: ["--skip", "B105"] - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. @@ -52,7 +58,7 @@ repos: hooks: - id: ruff exclude: tests/ - args: [--line-length=80] + args: [--line-length=100, --fix] - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.13.0 @@ -60,3 +66,20 @@ repos: - id: mypy exclude: tests/ additional_dependencies: [types-requests] + +- repo: local + hooks: + - id: prettier + name: prettier + entry: prettier + language: system + types: [python, json, yaml, markdown] + + - id: pytest + name: pytest + entry: pytest + language: python + types: [python] + pass_filenames: false + always_run: true + additional_dependencies: [responses, pytest-mock] diff --git a/PyTado/__main__.py b/PyTado/__main__.py index 5a04179..9362544 100644 --- a/PyTado/__main__.py +++ b/PyTado/__main__.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- """Module for querying and controlling Tado smart thermostats.""" @@ -54,14 +53,10 @@ def main(): required=True, help=("Tado username in the form of an email address."), ) - required_flags.add_argument( - "--password", required=True, help="Tado password." - ) + required_flags.add_argument("--password", required=True, help="Tado password.") # Flags with default values go here. - log_levels = dict( - (logging.getLevelName(level), level) for level in [10, 20, 30, 40, 50] - ) + log_levels = {logging.getLevelName(level): level for level in [10, 20, 30, 40, 50]} parser.add_argument( "--loglevel", default="INFO", @@ -71,30 +66,20 @@ def main(): subparsers = parser.add_subparsers() - show_config_parser = subparsers.add_parser( - "get_me", help="Get home information." - ) + show_config_parser = subparsers.add_parser("get_me", help="Get home information.") show_config_parser.set_defaults(func=get_me) - start_activity_parser = subparsers.add_parser( - "get_state", help="Get state of zone." - ) - start_activity_parser.add_argument( - "--zone", help="Zone to get the state of." - ) + start_activity_parser = subparsers.add_parser("get_state", help="Get state of zone.") + start_activity_parser.add_argument("--zone", help="Zone to get the state of.") start_activity_parser.set_defaults(func=get_state) - start_activity_parser = subparsers.add_parser( - "get_states", help="Get states of all zones." - ) + start_activity_parser = subparsers.add_parser("get_states", help="Get states of all zones.") start_activity_parser.set_defaults(func=get_states) start_activity_parser = subparsers.add_parser( "get_capabilities", help="Get capabilities of zone." ) - start_activity_parser.add_argument( - "--zone", help="Zone to get the capabilities of." - ) + start_activity_parser.add_argument("--zone", help="Zone to get the capabilities of.") start_activity_parser.set_defaults(func=get_capabilities) args = parser.parse_args() diff --git a/PyTado/const.py b/PyTado/const.py index 83b64b5..fa55e76 100644 --- a/PyTado/const.py +++ b/PyTado/const.py @@ -2,9 +2,7 @@ # Api credentials CLIENT_ID = "tado-web-app" -CLIENT_SECRET = ( - "wZaRN7rpjn3FoNyF5IFuxg9uMzYJcvOoQ8QWiIqS3hfk6gLhVlG57j5YNoZL2Rtc" -) +CLIENT_SECRET = "wZaRN7rpjn3FoNyF5IFuxg9uMzYJcvOoQ8QWiIqS3hfk6gLhVlG57j5YNoZL2Rtc" # Types TYPE_AIR_CONDITIONING = "AIR_CONDITIONING" @@ -49,12 +47,8 @@ CONST_HORIZONTAL_SWING_RIGHT = "RIGHT" # When we change the temperature setting, we need an overlay mode -CONST_OVERLAY_TADO_MODE = ( - "NEXT_TIME_BLOCK" # wait until tado changes the mode automatic -) -CONST_OVERLAY_MANUAL = ( - "MANUAL" # the user has changed the temperature or mode manually -) +CONST_OVERLAY_TADO_MODE = "NEXT_TIME_BLOCK" # wait until tado changes the mode automatic +CONST_OVERLAY_MANUAL = "MANUAL" # the user has changed the temperature or mode manually CONST_OVERLAY_TIMER = "TIMER" # the temperature will be reset after a timespan # Heat always comes first since we get the diff --git a/PyTado/http.py b/PyTado/http.py index 015b61e..5f98191 100644 --- a/PyTado/http.py +++ b/PyTado/http.py @@ -62,7 +62,7 @@ def __init__( self, endpoint: Endpoint = Endpoint.MY_API, command: str | None = None, - action: Action = Action.GET, + action: Action | str = Action.GET, payload: dict[str, Any] | None = None, domain: Domain = Domain.HOME, device: int | None = None, @@ -86,7 +86,7 @@ def __init__( self, endpoint: Endpoint = Endpoint.HOPS_API, command: str | None = None, - action: Action = Action.GET, + action: Action | str = Action.GET, payload: dict[str, Any] | None = None, domain: Domain = Domain.HOME, device: int | None = None, @@ -106,14 +106,14 @@ def __init__( self._action = action @property - def action(self) -> str: + def action(self) -> Action | str: """Get request action for Tado X""" if self._action == Action.CHANGE: return "PATCH" return self._action @action.setter - def action(self, value: Action): + def action(self, value: Action | str): """Set request action""" self._action = value @@ -151,6 +151,7 @@ def __init__( self._username = username self._password = password self._id, self._token_refresh = self._login() + self._x_api = self._check_x_line_generation() def _log_response(self, response: requests.Response, *args, **kwargs): @@ -176,9 +177,7 @@ def request(self, request: TadoRequest) -> dict[str, Any]: url = self._configure_url(request) - http_request = requests.Request( - request.action, url, headers=headers, data=data - ) + http_request = requests.Request(request.action, url, headers=headers, data=data) prepped = http_request.prepare() retries = _DEFAULT_RETRIES @@ -221,14 +220,17 @@ def _configure_url(self, request: TadoRequest) -> str: elif request.domain == Domain.ME: url = f"{request.endpoint}{request.domain}" elif request.endpoint == Endpoint.MINDER: - url = f"{request.endpoint}{request.domain}/{self.id:d}/{request.command}?{urlencode(request.params)}" + params = request.params if request.params is not None else {} + + url = ( + f"{request.endpoint}{request.domain}/{self._id:d}/{request.command}" + f"?{urlencode(params)}" + ) else: url = f"{request.endpoint}{request.domain}/{self._id:d}/{request.command}" return url - def _configure_payload( - self, headers: dict[str, str], request: TadoRequest - ) -> bytes: + def _configure_payload(self, headers: dict[str, str], request: TadoRequest) -> bytes: if request.payload is None: return b"" @@ -292,7 +294,7 @@ def _refresh_token(self) -> None: f"Unknown error while refreshing token with status code {response.status_code}" ) - def _login(self) -> tuple[int, str] | None: + def _login(self) -> tuple[int, str]: headers = self._headers headers["Content-Type"] = "application/json" @@ -320,25 +322,26 @@ def _login(self) -> tuple[int, str] | None: ) if response.status_code == 400: - raise TadoWrongCredentialsException( - "Your username or password is invalid" - ) + raise TadoWrongCredentialsException("Your username or password is invalid") - if response.status_code == 200: - refresh_token = self._set_oauth_header(response.json()) - id_ = self._get_id() + if response.status_code != 200: + raise TadoException( + f"Login failed for unknown reason with status code {response.status_code}" + ) - return id_, refresh_token + refresh_token = self._set_oauth_header(response.json()) + id_ = self._get_id() - raise TadoException( - f"Login failed for unknown reason with status code {response.status_code}" - ) + return id_, refresh_token def _get_id(self) -> int: request = TadoRequest() request.action = Action.GET request.domain = Domain.ME - return self.request(request)["homes"][0]["id"] + + homes_ = self.request(request)["homes"] + + return homes_[0]["id"] def _check_x_line_generation(self): # get home info @@ -347,5 +350,6 @@ def _check_x_line_generation(self): request.domain = Domain.HOME request.command = "" - home = self.request(request) - return "generation" in home and home["generation"] == "LINE_X" + home_ = self.request(request) + + return "generation" in home_ and home_["generation"] == "LINE_X" diff --git a/PyTado/interface/__init__.py b/PyTado/interface/__init__.py index fefdbdd..387b9fc 100644 --- a/PyTado/interface/__init__.py +++ b/PyTado/interface/__init__.py @@ -1,3 +1,5 @@ """Abstraction layer for API implementation.""" from .interface import Tado + +__all__ = ["Tado"] diff --git a/PyTado/interface/api/__init__.py b/PyTado/interface/api/__init__.py index 6e3af53..a5076fb 100644 --- a/PyTado/interface/api/__init__.py +++ b/PyTado/interface/api/__init__.py @@ -2,3 +2,5 @@ from .hops_tado import TadoX from .my_tado import Tado + +__all__ = ["Tado", "TadoX"] diff --git a/PyTado/interface/api/hops_tado.py b/PyTado/interface/api/hops_tado.py index befd806..2be9d7c 100644 --- a/PyTado/interface/api/hops_tado.py +++ b/PyTado/interface/api/hops_tado.py @@ -3,23 +3,13 @@ """ import logging - from typing import Any -from .my_tado import Tado, Timetable - -from ...logger import Logger from ...exceptions import TadoNotSupportedException -from ...http import ( - Action, - Domain, - Http, - Mode, - TadoRequest, - TadoXRequest, -) -from ...zone import TadoZone, TadoXZone - +from ...http import Action, Domain, Http, Mode, TadoRequest, TadoXRequest +from ...logger import Logger +from ...zone import TadoXZone, TadoZone +from .my_tado import Tado, Timetable _LOGGER = Logger(__name__) @@ -42,9 +32,7 @@ def __init__( super().__init__(http=http, debug=debug) if not http.is_x_line: - raise TadoNotSupportedException( - "TadoX is only usable with LINE_X Generation" - ) + raise TadoNotSupportedException("TadoX is only usable with LINE_X Generation") if debug: _LOGGER.setLevel(logging.DEBUG) @@ -124,9 +112,7 @@ def get_capabilities(self, zone): Gets current capabilities of zone. """ - raise TadoNotSupportedException( - "This method is not currently supported by the Tado X API" - ) + raise TadoNotSupportedException("This method is not currently supported by the Tado X API") def get_climate(self, zone): """ @@ -147,13 +133,9 @@ def set_timetable(self, zone: int, timetable: Timetable) -> None: id = 3 : SEVEN_DAY (MONDAY, TUESDAY, WEDNESDAY ...) """ - raise TadoNotSupportedException( - "Tado X API only support seven days timetable" - ) + raise TadoNotSupportedException("Tado X API only support seven days timetable") - def get_schedule( - self, zone: int, timetable: Timetable, day=None - ) -> dict[str, Any]: + def get_schedule(self, zone: int, timetable: Timetable, day=None) -> dict[str, Any]: """ Get the JSON representation of the schedule for a zone. Zone has 3 different schedules, one for each timetable (see setTimetable) @@ -293,18 +275,14 @@ def set_open_window(self, zone): Note: This can only be set if an open window was detected in this zone """ - raise TadoNotSupportedException( - "This method is not currently supported by the Tado X API" - ) + raise TadoNotSupportedException("This method is not currently supported by the Tado X API") def reset_open_window(self, zone): """ Sets the window in zone to closed """ - raise TadoNotSupportedException( - "This method is not currently supported by the Tado X API" - ) + raise TadoNotSupportedException("This method is not currently supported by the Tado X API") def get_device_info(self, device_id, cmd=""): """ diff --git a/PyTado/interface/api/my_tado.py b/PyTado/interface/api/my_tado.py index 254612d..091549c 100644 --- a/PyTado/interface/api/my_tado.py +++ b/PyTado/interface/api/my_tado.py @@ -2,22 +2,14 @@ PyTado interface implementation for app.tado.com. """ -import enum import datetime +import enum import logging - from typing import Any +from ...exceptions import TadoException, TadoNotSupportedException +from ...http import Action, Domain, Endpoint, Http, Mode, TadoRequest from ...logger import Logger -from ...exceptions import TadoNotSupportedException -from ...http import ( - Action, - Domain, - Endpoint, - Http, - Mode, - TadoRequest, -) from ...zone import TadoZone @@ -155,9 +147,7 @@ def get_home_state(self): # showSwitchToAutoGeofencingButton or currently enabled via the # presence of presenceLocked = False if "showSwitchToAutoGeofencingButton" in data: - self._auto_geofencing_supported = data[ - "showSwitchToAutoGeofencingButton" - ] + self._auto_geofencing_supported = data["showSwitchToAutoGeofencingButton"] elif "presenceLocked" in data: if not data["presenceLocked"]: self._auto_geofencing_supported = True @@ -210,9 +200,7 @@ def get_timetable(self, zone: int) -> Timetable: data = self._http.request(request) if "id" not in data: - raise TadoException( - f'Returned data did not contain "id" : {str(data)}' - ) + raise TadoException(f'Returned data did not contain "id" : {str(data)}') return Timetable(data["id"]) @@ -224,14 +212,10 @@ def get_historic(self, zone, date): try: day = datetime.datetime.strptime(date, "%Y-%m-%d") except ValueError as err: - raise ValueError( - "Incorrect date format, should be YYYY-MM-DD" - ) from err + raise ValueError("Incorrect date format, should be YYYY-MM-DD") from err request = TadoRequest() - request.command = ( - f"zones/{zone:d}/dayReport?date={day.strftime('%Y-%m-%d')}" - ) + request.command = f"zones/{zone:d}/dayReport?date={day.strftime('%Y-%m-%d')}" return self._http.request(request) def set_timetable(self, zone: int, timetable: Timetable) -> None: @@ -250,22 +234,16 @@ def set_timetable(self, zone: int, timetable: Timetable) -> None: self._http.request(request) - def get_schedule( - self, zone: int, timetable: Timetable, day=None - ) -> dict[str, Any]: + def get_schedule(self, zone: int, timetable: Timetable, day=None) -> dict[str, Any]: """ Get the JSON representation of the schedule for a zone. Zone has 3 different schedules, one for each timetable (see setTimetable) """ request = TadoRequest() if day: - request.command = ( - f"zones/{zone:d}/schedule/timetables/{timetable:d}/blocks/{day}" - ) + request.command = f"zones/{zone:d}/schedule/timetables/{timetable:d}/blocks/{day}" else: - request.command = ( - f"zones/{zone:d}/schedule/timetables/{timetable:d}/blocks" - ) + request.command = f"zones/{zone:d}/schedule/timetables/{timetable:d}/blocks" request.mode = Mode.PLAIN return self._http.request(request) @@ -276,9 +254,7 @@ def set_schedule(self, zone, timetable: Timetable, day, data): """ request = TadoRequest() - request.command = ( - f"zones/{zone:d}/schedule/timetables/{timetable:d}/blocks/{day}" - ) + request.command = f"zones/{zone:d}/schedule/timetables/{timetable:d}/blocks/{day}" request.action = Action.CHANGE request.payload = data request.mode = Mode.PLAIN @@ -437,9 +413,7 @@ def set_auto(self) -> None: return self._http.request(request) else: - raise TadoNotSupportedException( - "Auto mode is not known to be supported." - ) + raise TadoNotSupportedException("Auto mode is not known to be supported.") def get_window_state(self, zone): """ @@ -537,9 +511,7 @@ def get_eiq_meter_readings(self): return self._http.request(request) - def set_eiq_meter_readings( - self, date=datetime.datetime.now().strftime("%Y-%m-%d"), reading=0 - ): + def set_eiq_meter_readings(self, date=datetime.datetime.now().strftime("%Y-%m-%d"), reading=0): """ Send Meter Readings to Tado, date format is YYYY-MM-DD, reading is without decimals """ @@ -622,9 +594,7 @@ def set_zone_heating_circuit(self, zone, heating_circuit): return self._http.request(request) - def get_running_times( - self, date=datetime.datetime.now().strftime("%Y-%m-%d") - ) -> dict: + def get_running_times(self, date=datetime.datetime.now().strftime("%Y-%m-%d")) -> dict: """ Get the running times from the Minder API """ diff --git a/PyTado/interface/interface.py b/PyTado/interface/interface.py index e131420..52c3f53 100644 --- a/PyTado/interface/interface.py +++ b/PyTado/interface/interface.py @@ -3,11 +3,11 @@ """ import datetime -import warnings import functools +import warnings -from PyTado.http import Http import PyTado.interface.api as API +from PyTado.http import Http def deprecated(new_func_name): @@ -51,7 +51,7 @@ def __init__( ) if self._http.is_x_line: - self._api = API.TadoX(http=self._http, debug=debug) + self._api: API.Tado | API.TadoX = API.TadoX(http=self._http, debug=debug) else: self._api = API.Tado(http=self._http, debug=debug) @@ -253,9 +253,7 @@ def getDeviceInfo(self, device_id, cmd=""): @deprecated("set_temp_offset") def setTempOffset(self, device_id, offset=0, measure="celsius"): """Set the Temperature offset on the device. (Deprecated)""" - return self.set_temp_offset( - device_id=device_id, offset=offset, measure=measure - ) + return self.set_temp_offset(device_id=device_id, offset=offset, measure=measure) @deprecated("get_eiq_tariffs") def getEIQTariffs(self): @@ -268,10 +266,11 @@ def getEIQMeterReadings(self): return self.get_eiq_meter_readings() @deprecated("set_eiq_meter_readings") - def setEIQMeterReadings( - self, date=datetime.datetime.now().strftime("%Y-%m-%d"), reading=0 - ): - """Send Meter Readings to Tado, date format is YYYY-MM-DD, reading is without decimals (Deprecated)""" + def setEIQMeterReadings(self, date=datetime.datetime.now().strftime("%Y-%m-%d"), reading=0): + """Send Meter Readings to Tado (Deprecated) + + date format is YYYY-MM-DD, reading is without decimals + """ return self.set_eiq_meter_readings(date=date, reading=reading) @deprecated("set_eiq_tariff") @@ -283,8 +282,10 @@ def setEIQTariff( unit="m3", is_period=False, ): - """Send Tariffs to Tado, date format is YYYY-MM-DD, - tariff is with decimals, unit is either m3 or kWh, set is_period to true to set a period of price (Deprecated) + """Send Tariffs to Tado (Deprecated) + + date format is YYYY-MM-DD, tariff is with decimals, unit is either + m3 or kWh, set is_period to true to set a period of price """ return self.set_eiq_tariff( from_date=from_date, diff --git a/PyTado/logger.py b/PyTado/logger.py index 518279b..b75fef3 100644 --- a/PyTado/logger.py +++ b/PyTado/logger.py @@ -35,16 +35,12 @@ def format(self, record): """ Do the actual filtering """ - original = logging.Formatter.format( - self, record - ) # call parent method + original = logging.Formatter.format(self, record) # call parent method return self._filter(original) def __init__(self, name: str, level=logging.NOTSET): super().__init__(name) log_sh = logging.StreamHandler() - log_fmt = self.SensitiveFormatter( - fmt="%(name)s :: %(levelname)-8s :: %(message)s" - ) + log_fmt = self.SensitiveFormatter(fmt="%(name)s :: %(levelname)-8s :: %(message)s") log_sh.setFormatter(log_fmt) self.addHandler(log_sh) diff --git a/PyTado/zone/__init__.py b/PyTado/zone/__init__.py index 9d3737f..fb13f97 100644 --- a/PyTado/zone/__init__.py +++ b/PyTado/zone/__init__.py @@ -2,3 +2,5 @@ from .hops_zone import TadoXZone from .my_zone import TadoZone + +__all__ = ["TadoZone", "TadoXZone"] diff --git a/PyTado/zone/hops_zone.py b/PyTado/zone/hops_zone.py index d893b66..fdd1a82 100644 --- a/PyTado/zone/hops_zone.py +++ b/PyTado/zone/hops_zone.py @@ -7,19 +7,19 @@ from typing import Any, Self from PyTado.const import ( + CONST_CONNECTION_OFFLINE, + CONST_HORIZONTAL_SWING_OFF, CONST_HVAC_HEAT, CONST_HVAC_IDLE, CONST_HVAC_OFF, - CONST_CONNECTION_OFFLINE, CONST_MODE_HEAT, CONST_MODE_OFF, CONST_MODE_SMART_SCHEDULE, CONST_VERTICAL_SWING_OFF, - CONST_HORIZONTAL_SWING_OFF, DEFAULT_TADOX_PRECISION, ) -from .my_zone import TadoZone +from .my_zone import TadoZone _LOGGER = logging.getLogger(__name__) @@ -46,15 +46,11 @@ def from_data(cls, zone_id: int, data: dict[str, Any]) -> Self: kwargs["current_temp"] = float(inside_temp["value"]) kwargs["current_temp_timestamp"] = None if "precision" in sensor_data["insideTemperature"]: - kwargs["precision"] = sensor_data["insideTemperature"][ - "precision" - ]["celsius"] + kwargs["precision"] = sensor_data["insideTemperature"]["precision"]["celsius"] # X-specific humidity parsing if "humidity" in sensor_data: - kwargs["current_humidity"] = float( - sensor_data["humidity"]["percentage"] - ) + kwargs["current_humidity"] = float(sensor_data["humidity"]["percentage"]) kwargs["current_humidity_timestamp"] = None # Tado mode processing @@ -74,13 +70,8 @@ def from_data(cls, zone_id: int, data: dict[str, Any]) -> Self: # Setting processing if "setting" in data: # X-specific temperature setting - if ( - "temperature" in data["setting"] - and data["setting"]["temperature"] is not None - ): - kwargs["target_temp"] = float( - data["setting"]["temperature"]["value"] - ) + if "temperature" in data["setting"] and data["setting"]["temperature"] is not None: + kwargs["target_temp"] = float(data["setting"]["temperature"]["value"]) setting = data["setting"] @@ -106,9 +97,7 @@ def from_data(cls, zone_id: int, data: dict[str, Any]) -> Self: else: kwargs["current_hvac_action"] = CONST_HVAC_HEAT - kwargs["heating_power_percentage"] = data["heatingPower"][ - "percentage" - ] + kwargs["heating_power_percentage"] = data["heatingPower"]["percentage"] else: kwargs["heating_power_percentage"] = 0 kwargs["current_hvac_action"] = CONST_HVAC_OFF @@ -120,12 +109,8 @@ def from_data(cls, zone_id: int, data: dict[str, Any]) -> Self: kwargs["current_hvac_mode"] = ( CONST_MODE_HEAT if power == "ON" else CONST_MODE_OFF ) - kwargs["overlay_termination_type"] = manual_termination[ - "type" - ] - kwargs["overlay_termination_timestamp"] = ( - manual_termination["projectedExpiry"] - ) + kwargs["overlay_termination_type"] = manual_termination["type"] + kwargs["overlay_termination_timestamp"] = manual_termination["projectedExpiry"] else: kwargs["current_hvac_mode"] = CONST_MODE_SMART_SCHEDULE kwargs["overlay_termination_type"] = None @@ -133,17 +118,15 @@ def from_data(cls, zone_id: int, data: dict[str, Any]) -> Self: else: kwargs["current_hvac_mode"] = CONST_MODE_SMART_SCHEDULE - kwargs["available"] = ( - kwargs.get("connection") != CONST_CONNECTION_OFFLINE - ) + kwargs["available"] = kwargs.get("connection") != CONST_CONNECTION_OFFLINE # Termination conditions if "terminationCondition" in data: - kwargs["default_overlay_termination_type"] = data[ - "terminationCondition" - ].get("type", None) - kwargs["default_overlay_termination_duration"] = data[ - "terminationCondition" - ].get("durationInSeconds", None) + kwargs["default_overlay_termination_type"] = data["terminationCondition"].get( + "type", None + ) + kwargs["default_overlay_termination_duration"] = data["terminationCondition"].get( + "durationInSeconds", None + ) return cls(zone_id=zone_id, **kwargs) diff --git a/PyTado/zone/my_zone.py b/PyTado/zone/my_zone.py index 81c6c83..0d0417f 100644 --- a/PyTado/zone/my_zone.py +++ b/PyTado/zone/my_zone.py @@ -9,6 +9,9 @@ from PyTado.const import ( CONST_FAN_AUTO, CONST_FAN_OFF, + CONST_FAN_SPEED_AUTO, + CONST_FAN_SPEED_OFF, + CONST_HORIZONTAL_SWING_OFF, CONST_HVAC_COOL, CONST_HVAC_HEAT, CONST_HVAC_IDLE, @@ -16,14 +19,11 @@ CONST_LINK_OFFLINE, CONST_MODE_OFF, CONST_MODE_SMART_SCHEDULE, + CONST_VERTICAL_SWING_OFF, DEFAULT_TADO_PRECISION, TADO_HVAC_ACTION_TO_MODES, TADO_MODES_TO_HVAC_ACTION, TYPE_AIR_CONDITIONING, - CONST_VERTICAL_SWING_OFF, - CONST_HORIZONTAL_SWING_OFF, - CONST_FAN_SPEED_AUTO, - CONST_FAN_SPEED_OFF, ) _LOGGER = logging.getLogger(__name__) @@ -35,7 +35,6 @@ class TadoZone: zone_id: int current_temp: float | None = None - connection: str | None = None current_temp_timestamp: str | None = None current_humidity: float | None = None current_humidity_timestamp: str | None = None @@ -82,23 +81,15 @@ def from_data(cls, zone_id: int, data: dict[str, Any]) -> Self: sensor_data = data["sensorDataPoints"] if "insideTemperature" in sensor_data: - kwargs["current_temp"] = float( - sensor_data["insideTemperature"]["celsius"] - ) - kwargs["current_temp_timestamp"] = sensor_data[ - "insideTemperature" - ]["timestamp"] + kwargs["current_temp"] = float(sensor_data["insideTemperature"]["celsius"]) + kwargs["current_temp_timestamp"] = sensor_data["insideTemperature"]["timestamp"] if "precision" in sensor_data["insideTemperature"]: - kwargs["precision"] = sensor_data["insideTemperature"][ - "precision" - ]["celsius"] + kwargs["precision"] = sensor_data["insideTemperature"]["precision"]["celsius"] if "humidity" in sensor_data: humidity = float(sensor_data["humidity"]["percentage"]) kwargs["current_humidity"] = humidity - kwargs["current_humidity_timestamp"] = sensor_data["humidity"][ - "timestamp" - ] + kwargs["current_humidity_timestamp"] = sensor_data["humidity"]["timestamp"] if "tadoMode" in data: kwargs["is_away"] = data["tadoMode"] == "AWAY" @@ -112,13 +103,8 @@ def from_data(cls, zone_id: int, data: dict[str, Any]) -> Self: if "setting" in data: # temperature setting will not exist when device is off - if ( - "temperature" in data["setting"] - and data["setting"]["temperature"] is not None - ): - kwargs["target_temp"] = float( - data["setting"]["temperature"]["celsius"] - ) + if "temperature" in data["setting"] and data["setting"]["temperature"] is not None: + kwargs["target_temp"] = float(data["setting"]["temperature"]["celsius"]) setting = data["setting"] @@ -145,9 +131,7 @@ def from_data(cls, zone_id: int, data: dict[str, Any]) -> Self: kwargs["current_vertical_swing_mode"] = setting["verticalSwing"] if "horizontalSwing" in setting: - kwargs["current_horizontal_swing_mode"] = setting[ - "horizontalSwing" - ] + kwargs["current_horizontal_swing_mode"] = setting["horizontalSwing"] power = setting["power"] kwargs["power"] = power @@ -160,9 +144,7 @@ def from_data(cls, zone_id: int, data: dict[str, Any]) -> Self: ): # v2 devices do not have mode so we have to figure it out # from type - kwargs["current_hvac_mode"] = TADO_HVAC_ACTION_TO_MODES[ - setting["type"] - ] + kwargs["current_hvac_mode"] = TADO_HVAC_ACTION_TO_MODES[setting["type"]] # Not all devices have fans if "fanSpeed" in setting: @@ -171,23 +153,15 @@ def from_data(cls, zone_id: int, data: dict[str, Any]) -> Self: CONST_FAN_AUTO if power == "ON" else CONST_FAN_OFF, ) elif "type" in setting and setting["type"] == TYPE_AIR_CONDITIONING: - kwargs["current_fan_speed"] = ( - CONST_FAN_AUTO if power == "ON" else CONST_FAN_OFF - ) + kwargs["current_fan_speed"] = CONST_FAN_AUTO if power == "ON" else CONST_FAN_OFF if "fanLevel" in setting: kwargs["current_fan_level"] = setting.get( "fanLevel", - ( - CONST_FAN_SPEED_AUTO - if power == "ON" - else CONST_FAN_SPEED_OFF - ), + (CONST_FAN_SPEED_AUTO if power == "ON" else CONST_FAN_SPEED_OFF), ) - kwargs["preparation"] = ( - "preparation" in data and data["preparation"] is not None - ) + kwargs["preparation"] = "preparation" in data and data["preparation"] is not None open_window = data.get("openWindow") is not None kwargs["open_window"] = open_window kwargs["open_window_detected"] = data.get("openWindowDetected", False) @@ -195,32 +169,18 @@ def from_data(cls, zone_id: int, data: dict[str, Any]) -> Self: if "activityDataPoints" in data: activity_data = data["activityDataPoints"] - if ( - "acPower" in activity_data - and activity_data["acPower"] is not None - ): + if "acPower" in activity_data and activity_data["acPower"] is not None: kwargs["ac_power"] = activity_data["acPower"]["value"] - kwargs["ac_power_timestamp"] = activity_data["acPower"][ - "timestamp" - ] + kwargs["ac_power_timestamp"] = activity_data["acPower"]["timestamp"] if activity_data["acPower"]["value"] == "ON" and power == "ON": # acPower means the unit has power so we need to map the # mode - kwargs["current_hvac_action"] = ( - TADO_MODES_TO_HVAC_ACTION.get( - kwargs["current_hvac_mode"], CONST_HVAC_COOL - ) + kwargs["current_hvac_action"] = TADO_MODES_TO_HVAC_ACTION.get( + kwargs["current_hvac_mode"], CONST_HVAC_COOL ) - if ( - "heatingPower" in activity_data - and activity_data["heatingPower"] is not None - ): - kwargs["heating_power"] = activity_data["heatingPower"].get( - "value", None - ) - kwargs["heating_power_timestamp"] = activity_data[ - "heatingPower" - ]["timestamp"] + if "heatingPower" in activity_data and activity_data["heatingPower"] is not None: + kwargs["heating_power"] = activity_data["heatingPower"].get("value", None) + kwargs["heating_power_timestamp"] = activity_data["heatingPower"]["timestamp"] kwargs["heating_power_percentage"] = float( activity_data["heatingPower"].get("percentage", 0) ) @@ -231,32 +191,25 @@ def from_data(cls, zone_id: int, data: dict[str, Any]) -> Self: # If there is no overlay # then we are running the smart schedule if "overlay" in data and data["overlay"] is not None: - if ( - "termination" in data["overlay"] - and "type" in data["overlay"]["termination"] - ): - kwargs["overlay_termination_type"] = data["overlay"][ - "termination" - ]["type"] - kwargs["overlay_termination_timestamp"] = data["overlay"][ - "termination" - ].get("expiry", None) + if "termination" in data["overlay"] and "type" in data["overlay"]["termination"]: + kwargs["overlay_termination_type"] = data["overlay"]["termination"]["type"] + kwargs["overlay_termination_timestamp"] = data["overlay"]["termination"].get( + "expiry", None + ) else: kwargs["current_hvac_mode"] = CONST_MODE_SMART_SCHEDULE kwargs["connection"] = ( - data["connectionState"]["value"] - if "connectionState" in data - else None + data["connectionState"]["value"] if "connectionState" in data else None ) kwargs["available"] = kwargs["link"] != CONST_LINK_OFFLINE if "terminationCondition" in data: - kwargs["default_overlay_termination_type"] = data[ - "terminationCondition" - ].get("type", None) - kwargs["default_overlay_termination_duration"] = data[ - "terminationCondition" - ].get("durationInSeconds", None) + kwargs["default_overlay_termination_type"] = data["terminationCondition"].get( + "type", None + ) + kwargs["default_overlay_termination_duration"] = data["terminationCondition"].get( + "durationInSeconds", None + ) return cls(zone_id=zone_id, **kwargs) diff --git a/pyproject.toml b/pyproject.toml index bc53d27..d5a3c9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ platforms = ["any"] zip-safe = false [tool.black] -line-length = 80 +line-length = 100 target-version = ['py311'] [tool.pytype] @@ -211,7 +211,7 @@ generated-members = '' [tool.pylint.FORMAT] -max-line-length = 80 +max-line-length = 100 ignore-long-lines = '''(?x)( ^\s*(\#\ )??$| @@ -307,3 +307,8 @@ class_ # List of valid names for the first argument in a metaclass class method.valid-metaclass-classmethod-first-arg=mcs # List of method names used to declare (i.e. assign) instance attributes. # warning. + +[tool.pytest.ini_options] +testpaths = [ + "tests", +] diff --git a/tests/common.py b/tests/common.py index c684b5e..0fe57cb 100644 --- a/tests/common.py +++ b/tests/common.py @@ -6,5 +6,5 @@ def load_fixture(filename: str) -> str: """Load a fixture.""" path = os.path.join(os.path.dirname(__file__), "fixtures", filename) - with open(path, "r") as fd: + with open(path) as fd: return fd.read() From 904f92a69e553b50833cf29d6b4ea4a88c0cc48d Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Wed, 25 Dec 2024 12:59:31 +0100 Subject: [PATCH 35/49] Refactoring by adding a decorator to hops_tado.py (#122) --- .gitignore | 2 ++ PyTado/interface/api/hops_tado.py | 35 ++++++++++++++++++++----------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 68bc17f..dbce267 100644 --- a/.gitignore +++ b/.gitignore @@ -158,3 +158,5 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +.DS_Store diff --git a/PyTado/interface/api/hops_tado.py b/PyTado/interface/api/hops_tado.py index 2be9d7c..7251369 100644 --- a/PyTado/interface/api/hops_tado.py +++ b/PyTado/interface/api/hops_tado.py @@ -2,6 +2,7 @@ PyTado interface implementation for hops.tado.com (Tado X). """ +import functools import logging from typing import Any @@ -11,6 +12,18 @@ from ...zone import TadoXZone, TadoZone from .my_tado import Tado, Timetable + +def not_supported(reason): + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + raise TadoNotSupportedException(f"{func.__name__} is not supported: {reason}") + + return wrapper + + return decorator + + _LOGGER = Logger(__name__) @@ -107,12 +120,12 @@ def get_state(self, zone): return data + @not_supported("This method is not currently supported by the Tado X API") def get_capabilities(self, zone): """ Gets current capabilities of zone. """ - - raise TadoNotSupportedException("This method is not currently supported by the Tado X API") + pass def get_climate(self, zone): """ @@ -125,6 +138,7 @@ def get_climate(self, zone): "humidity": data["humidity"]["percentage"], } + @not_supported("Tado X API only support seven days timetable") def set_timetable(self, zone: int, timetable: Timetable) -> None: """ Set the Timetable type currently active @@ -132,8 +146,7 @@ def set_timetable(self, zone: int, timetable: Timetable) -> None: id = 1 : THREE_DAY (MONDAY_TO_FRIDAY, SATURDAY, SUNDAY) id = 3 : SEVEN_DAY (MONDAY, TUESDAY, WEDNESDAY ...) """ - - raise TadoNotSupportedException("Tado X API only support seven days timetable") + pass def get_schedule(self, zone: int, timetable: Timetable, day=None) -> dict[str, Any]: """ @@ -248,14 +261,12 @@ def set_zone_overlay( return self._http.request(request) + @not_supported("Concept of zones is not available by Tado X API, they use rooms") def get_zone_overlay_default(self, zone: int): """ Get current overlay default settings for zone. """ - - raise TadoNotSupportedException( - "Concept of zones is not available by Tado X API, they use rooms" - ) + pass def get_open_window_detected(self, zone): """ @@ -269,20 +280,20 @@ def get_open_window_detected(self, zone): else: return {"openWindowDetected": False} + @not_supported("This method is not currently supported by the Tado X API") def set_open_window(self, zone): """ Sets the window in zone to open Note: This can only be set if an open window was detected in this zone """ + pass - raise TadoNotSupportedException("This method is not currently supported by the Tado X API") - + @not_supported("This method is not currently supported by the Tado X API") def reset_open_window(self, zone): """ Sets the window in zone to closed """ - - raise TadoNotSupportedException("This method is not currently supported by the Tado X API") + pass def get_device_info(self, device_id, cmd=""): """ From 0be233d2f3397edc9b3f4c3f6940a631007087cd Mon Sep 17 00:00:00 2001 From: Malachi Soord Date: Sat, 28 Dec 2024 11:13:21 +0100 Subject: [PATCH 36/49] Tidy pre-commit config + setup CI (#130) --- .github/workflows/pre-commit.yml | 29 +++++++++++++++++++++++++++++ .pre-commit-config.yaml | 9 +-------- 2 files changed, 30 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/pre-commit.yml diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 0000000..3756ff4 --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches-ignore: + - master + pull_request: ~ + +env: + FORCE_COLOR: 1 + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: 3.12 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e '.[all]' + + - name: Run pre-commit hooks + uses: pre-commit/action@v3.0.1 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8ff4cd9..3d753d7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,13 +31,6 @@ repos: hooks: - id: pyupgrade -- repo: https://github.com/psf/black - rev: 24.10.0 - hooks: - - id: black - exclude: tests/ - args: [--line-length=100] - - repo: https://github.com/PyCQA/flake8 rev: 7.1.1 hooks: @@ -61,7 +54,7 @@ repos: args: [--line-length=100, --fix] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.13.0 + rev: v1.14.0 hooks: - id: mypy exclude: tests/ From 858733fe74c6d68af67bde24bed076017da57178 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sat, 28 Dec 2024 12:24:01 +0100 Subject: [PATCH 37/49] Adding minor improvements (#125) Co-authored-by: Wolfgang Malgadey --- .devcontainer/devcontainer.json | 4 +- .pre-commit-config.yaml | 17 ++++-- PyTado/http.py | 105 +++++++++++++++++--------------- 3 files changed, 69 insertions(+), 57 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 7d9b3ad..0a7eefd 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -58,5 +58,7 @@ } }, "image": "mcr.microsoft.com/vscode/devcontainers/python:3.12", - "name": "PyTado" + "name": "PyTado", + "postCreateCommand": "python3 -m venv venv && . venv/bin/activate && pip install --upgrade pip && pip install -e '.[all]' && pre-commit install", + "updateContentCommand": "python3 -m venv venv && . venv/bin/activate && pip install --upgrade pip && pip install -e '.[all]' && pre-commit install" } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3d753d7..eaa6633 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,24 +26,29 @@ repos: exclude: tests/ args: ["--profile", "black"] -- repo: https://github.com/asottile/pyupgrade - rev: v3.19.1 +- repo: https://github.com/pre-commit/mirrors-autopep8 + rev: v1.5.7 hooks: - - id: pyupgrade + - id: autopep8 + exclude: tests/ + args: [--max-line-length=100, --aggressive, --aggressive] - repo: https://github.com/PyCQA/flake8 rev: 7.1.1 hooks: - id: flake8 exclude: tests/ - args: [--max-line-length=100 ] + args: [--max-line-length=100] + +- repo: https://github.com/asottile/pyupgrade + rev: v3.19.1 + hooks: + - id: pyupgrade - repo: https://github.com/PyCQA/bandit rev: '1.8.0' hooks: - id: bandit - exclude: tests/ - args: ["--skip", "B105"] - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. diff --git a/PyTado/http.py b/PyTado/http.py index 5f98191..360be08 100644 --- a/PyTado/http.py +++ b/PyTado/http.py @@ -68,7 +68,7 @@ def __init__( device: int | None = None, mode: Mode = Mode.OBJECT, params: dict[str, Any] | None = None, - ): + ) -> None: self.endpoint = endpoint self.command = command self.action = action @@ -92,7 +92,7 @@ def __init__( device: int | None = None, mode: Mode = Mode.OBJECT, params: dict[str, Any] | None = None, - ): + ) -> None: super().__init__( endpoint=endpoint, command=command, @@ -113,7 +113,7 @@ def action(self) -> Action | str: return self._action @action.setter - def action(self, value: Action | str): + def action(self, value: Action | str) -> None: """Set request action""" self._action = value @@ -138,7 +138,7 @@ def __init__( password: str, http_session: requests.Session | None = None, debug: bool = False, - ): + ) -> None: if debug: _LOGGER.setLevel(logging.DEBUG) else: @@ -154,7 +154,11 @@ def __init__( self._x_api = self._check_x_line_generation() - def _log_response(self, response: requests.Response, *args, **kwargs): + @property + def is_x_line(self) -> bool: + return self._x_api + + def _log_response(self, response: requests.Response, *args, **kwargs) -> None: og_request_method = response.request.method og_request_url = response.request.url og_request_headers = response.request.headers @@ -172,12 +176,10 @@ def request(self, request: TadoRequest) -> dict[str, Any]: self._refresh_token() headers = self._headers - data = self._configure_payload(headers, request) - url = self._configure_url(request) - http_request = requests.Request(request.action, url, headers=headers, data=data) + http_request = requests.Request(method=request.action, url=url, headers=headers, data=data) prepped = http_request.prepare() retries = _DEFAULT_RETRIES @@ -201,17 +203,13 @@ def request(self, request: TadoRequest) -> dict[str, Any]: _DEFAULT_RETRIES, e, ) - raise e + raise TadoException(e) if response.text is None or response.text == "": return {} return response.json() - @property - def is_x_line(self): - return self._x_api - def _configure_url(self, request: TadoRequest) -> str: if request.endpoint == Endpoint.MOBILE: url = f"{request.endpoint}{request.command}" @@ -242,6 +240,7 @@ def _configure_payload(self, headers: dict[str, str], request: TadoRequest) -> b return json.dumps(request.payload).encode("utf8") def _set_oauth_header(self, data: dict[str, Any]) -> str: + """Set the OAuth header and return the refresh token""" access_token = data["access_token"] expires_in = float(data["expires_in"]) @@ -250,15 +249,15 @@ def _set_oauth_header(self, data: dict[str, Any]) -> str: self._token_refresh = refresh_token self._refresh_at = datetime.now() self._refresh_at = self._refresh_at + timedelta(seconds=expires_in) - # we subtract 30 seconds from the correct refresh time - # then we have a 30 seconds timespan to get a new refresh_token + # We subtract 30 seconds from the correct refresh time. + # Then we have a 30 seconds timespan to get a new refresh_token self._refresh_at = self._refresh_at - timedelta(seconds=30) - self._headers["Authorization"] = "Bearer " + access_token + self._headers["Authorization"] = f"Bearer {access_token}" return refresh_token def _refresh_token(self) -> None: - + """Refresh the token if it is about to expire""" if self._refresh_at >= datetime.now(): return @@ -274,52 +273,58 @@ def _refresh_token(self) -> None: self._session = requests.Session() self._session.hooks["response"].append(self._log_response) - response = self._session.request( - "post", - url, - params=data, - timeout=_DEFAULT_TIMEOUT, - data=json.dumps({}).encode("utf8"), - headers={ - "Content-Type": "application/json", - "Referer": "https://app.tado.com/", - }, - ) + try: + response = self._session.request( + "post", + url, + params=data, + timeout=_DEFAULT_TIMEOUT, + data=json.dumps({}).encode("utf8"), + headers={ + "Content-Type": "application/json", + "Referer": "https://app.tado.com/", + }, + ) + except requests.exceptions.ConnectionError as e: + _LOGGER.error("Connection error: %s", e) + raise TadoException(e) - if response.status_code == 200: - self._set_oauth_header(response.json()) - return + if response.status_code != 200: + raise TadoWrongCredentialsException( + "Failed to refresh token, probably wrong credentials. " + f"Status code: {response.status_code}" + ) - raise TadoException( - f"Unknown error while refreshing token with status code {response.status_code}" - ) + self._set_oauth_header(response.json()) def _login(self) -> tuple[int, str]: - - headers = self._headers - headers["Content-Type"] = "application/json" + """Login to the API and get the refresh token""" url = "https://auth.tado.com/oauth/token" data = { - "client_id": "tado-web-app", - "client_secret": "wZaRN7rpjn3FoNyF5IFuxg9uMzYJcvOoQ8QWiIqS3hfk6gLhVlG57j5YNoZL2Rtc", + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, "grant_type": "password", "password": self._password, "scope": "home.user", "username": self._username, } - response = self._session.request( - "post", - url, - params=data, - timeout=_DEFAULT_TIMEOUT, - data=json.dumps({}).encode("utf8"), - headers={ - "Content-Type": "application/json", - "Referer": "https://app.tado.com/", - }, - ) + try: + response = self._session.request( + "post", + url, + params=data, + timeout=_DEFAULT_TIMEOUT, + data=json.dumps({}).encode("utf8"), + headers={ + "Content-Type": "application/json", + "Referer": "https://app.tado.com/", + }, + ) + except requests.exceptions.ConnectionError as e: + _LOGGER.error("Connection error: %s", e) + raise TadoException(e) if response.status_code == 400: raise TadoWrongCredentialsException("Your username or password is invalid") From f938ee39b57503bcd477c1dec52231c2073bdc1d Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Sat, 28 Dec 2024 12:54:28 +0100 Subject: [PATCH 38/49] Small fixes (#133) --- .devcontainer/devcontainer.json | 14 +++++++++++--- .pre-commit-config.yaml | 6 ++++-- PyTado/const.py | 4 ++-- pyproject.toml | 5 +++++ 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 0a7eefd..7d94b07 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -19,8 +19,8 @@ "settings": { "[python]": { "editor.codeActionsOnSave": { - "source.fixAll": true, - "source.organizeImports": true + "source.fixAll": "always", + "source.organizeImports": "always" } }, "coverage-gutters.customizable.context-menu": true, @@ -60,5 +60,13 @@ "image": "mcr.microsoft.com/vscode/devcontainers/python:3.12", "name": "PyTado", "postCreateCommand": "python3 -m venv venv && . venv/bin/activate && pip install --upgrade pip && pip install -e '.[all]' && pre-commit install", - "updateContentCommand": "python3 -m venv venv && . venv/bin/activate && pip install --upgrade pip && pip install -e '.[all]' && pre-commit install" + "updateContentCommand": "python3 -m venv venv && . venv/bin/activate && pip install --upgrade pip && pip install -e '.[all]' && pre-commit install", + "runArgs": [ + "--userns=keep-id" + ], + "containerUser": "vscode", + "updateRemoteUserUID": true, + "containerEnv": { + "HOME": "/home/vscode" + } } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eaa6633..a5445df 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,11 +27,11 @@ repos: args: ["--profile", "black"] - repo: https://github.com/pre-commit/mirrors-autopep8 - rev: v1.5.7 + rev: v2.0.4 hooks: - id: autopep8 exclude: tests/ - args: [--max-line-length=100, --aggressive, --aggressive] + args: [--max-line-length=100, --in-place, --aggressive] - repo: https://github.com/PyCQA/flake8 rev: 7.1.1 @@ -49,6 +49,8 @@ repos: rev: '1.8.0' hooks: - id: bandit + args: ["-c", "pyproject.toml"] + additional_dependencies: ["bandit[toml]"] - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. diff --git a/PyTado/const.py b/PyTado/const.py index fa55e76..f9ee4ba 100644 --- a/PyTado/const.py +++ b/PyTado/const.py @@ -1,8 +1,8 @@ """Constant values for the Tado component.""" # Api credentials -CLIENT_ID = "tado-web-app" -CLIENT_SECRET = "wZaRN7rpjn3FoNyF5IFuxg9uMzYJcvOoQ8QWiIqS3hfk6gLhVlG57j5YNoZL2Rtc" +CLIENT_ID = "tado-web-app" # nosec B105 +CLIENT_SECRET = "wZaRN7rpjn3FoNyF5IFuxg9uMzYJcvOoQ8QWiIqS3hfk6gLhVlG57j5YNoZL2Rtc" # nosec B105 # Types TYPE_AIR_CONDITIONING = "AIR_CONDITIONING" diff --git a/pyproject.toml b/pyproject.toml index d5a3c9b..b6a6bbe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -312,3 +312,8 @@ class_ testpaths = [ "tests", ] + +[tool.bandit] +exclude_dirs = ["tests"] +tests = [] +skips = [] From eaec632fe8e9aa8930f59ac31018bd5216262936 Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Sat, 28 Dec 2024 21:08:28 +0100 Subject: [PATCH 39/49] add home by bridge api to my tado (#134) --- PyTado/http.py | 31 +++++---- PyTado/interface/api/my_tado.py | 45 ++++++++++++ ..._bridge.boiler_max_output_temperature.json | 1 + ...idge.boiler_wiring_installation_state.json | 18 +++++ tests/test_my_tado.py | 69 +++++++++++++++---- 5 files changed, 140 insertions(+), 24 deletions(-) create mode 100644 tests/fixtures/home_by_bridge.boiler_max_output_temperature.json create mode 100644 tests/fixtures/home_by_bridge.boiler_wiring_installation_state.json diff --git a/PyTado/http.py b/PyTado/http.py index 360be08..21cc86b 100644 --- a/PyTado/http.py +++ b/PyTado/http.py @@ -37,6 +37,7 @@ class Domain(enum.StrEnum): HOME = "homes" DEVICES = "devices" ME = "me" + HOME_BY_BRIDGE = "homeByBridge" class Action(enum.StrEnum): @@ -65,7 +66,7 @@ def __init__( action: Action | str = Action.GET, payload: dict[str, Any] | None = None, domain: Domain = Domain.HOME, - device: int | None = None, + device: int | str | None = None, mode: Mode = Mode.OBJECT, params: dict[str, Any] | None = None, ) -> None: @@ -89,7 +90,7 @@ def __init__( action: Action | str = Action.GET, payload: dict[str, Any] | None = None, domain: Domain = Domain.HOME, - device: int | None = None, + device: int | str | None = None, mode: Mode = Mode.OBJECT, params: dict[str, Any] | None = None, ) -> None: @@ -163,12 +164,18 @@ def _log_response(self, response: requests.Response, *args, **kwargs) -> None: og_request_url = response.request.url og_request_headers = response.request.headers response_status = response.status_code + + if response.text is None or response.text == "": + response_data = {} + else: + response_data = response.json() + _LOGGER.debug( f"\nRequest:\n\tMethod:{og_request_method}" f"\n\tURL: {og_request_url}" f"\n\tHeaders: {pprint.pformat(og_request_headers)}" f"\nResponse:\n\tStatusCode: {response_status}" - f"\n\tData: {response.json()}" + f"\n\tData: {response_data}" ) def request(self, request: TadoRequest) -> dict[str, Any]: @@ -181,6 +188,7 @@ def request(self, request: TadoRequest) -> dict[str, Any]: http_request = requests.Request(method=request.action, url=url, headers=headers, data=data) prepped = http_request.prepare() + prepped.hooks["response"].append(self._log_response) retries = _DEFAULT_RETRIES @@ -196,6 +204,7 @@ def request(self, request: TadoRequest) -> dict[str, Any]: _LOGGER.warning("Connection error: %s", e) self._session.close() self._session = requests.Session() + self._session.hooks["response"].append(self._log_response) retries -= 1 else: _LOGGER.error( @@ -203,7 +212,7 @@ def request(self, request: TadoRequest) -> dict[str, Any]: _DEFAULT_RETRIES, e, ) - raise TadoException(e) + raise TadoException(e) from e if response.text is None or response.text == "": return {} @@ -213,19 +222,17 @@ def request(self, request: TadoRequest) -> dict[str, Any]: def _configure_url(self, request: TadoRequest) -> str: if request.endpoint == Endpoint.MOBILE: url = f"{request.endpoint}{request.command}" - elif request.domain == Domain.DEVICES: + elif request.domain == Domain.DEVICES or request.domain == Domain.HOME_BY_BRIDGE: url = f"{request.endpoint}{request.domain}/{request.device}/{request.command}" elif request.domain == Domain.ME: url = f"{request.endpoint}{request.domain}" - elif request.endpoint == Endpoint.MINDER: - params = request.params if request.params is not None else {} - - url = ( - f"{request.endpoint}{request.domain}/{self._id:d}/{request.command}" - f"?{urlencode(params)}" - ) else: url = f"{request.endpoint}{request.domain}/{self._id:d}/{request.command}" + + if request.params is not None: + params = request.params + url += f"?{urlencode(params)}" + return url def _configure_payload(self, headers: dict[str, str], request: TadoRequest) -> bytes: diff --git a/PyTado/interface/api/my_tado.py b/PyTado/interface/api/my_tado.py index 091549c..4044b85 100644 --- a/PyTado/interface/api/my_tado.py +++ b/PyTado/interface/api/my_tado.py @@ -606,3 +606,48 @@ def get_running_times(self, date=datetime.datetime.now().strftime("%Y-%m-%d")) - request.params = {"from": date} return self._http.request(request) + + def get_boiler_install_state(self, bridge_id: str, auth_key: str): + """ + Get the boiler wiring installation state from home by bridge endpoint + """ + + request = TadoRequest() + request.action = Action.GET + request.domain = Domain.HOME_BY_BRIDGE + request.device = bridge_id + request.command = "boilerWiringInstallationState" + request.params = {"authKey": auth_key} + + return self._http.request(request) + + def get_boiler_max_output_temperature(self, bridge_id: str, auth_key: str): + """ + Get the boiler max output temperature from home by bridge endpoint + """ + + request = TadoRequest() + request.action = Action.GET + request.domain = Domain.HOME_BY_BRIDGE + request.device = bridge_id + request.command = "boilerMaxOutputTemperature" + request.params = {"authKey": auth_key} + + return self._http.request(request) + + def set_boiler_max_output_temperature( + self, bridge_id: str, auth_key: str, temperature_in_celcius: float + ): + """ + Set the boiler max output temperature with home by bridge endpoint + """ + + request = TadoRequest() + request.action = Action.CHANGE + request.domain = Domain.HOME_BY_BRIDGE + request.device = bridge_id + request.command = "boilerMaxOutputTemperature" + request.params = {"authKey": auth_key} + request.payload = {"boilerMaxOutputTemperatureInCelsius": temperature_in_celcius} + + return self._http.request(request) diff --git a/tests/fixtures/home_by_bridge.boiler_max_output_temperature.json b/tests/fixtures/home_by_bridge.boiler_max_output_temperature.json new file mode 100644 index 0000000..609c8ef --- /dev/null +++ b/tests/fixtures/home_by_bridge.boiler_max_output_temperature.json @@ -0,0 +1 @@ +{"boilerMaxOutputTemperatureInCelsius":50} diff --git a/tests/fixtures/home_by_bridge.boiler_wiring_installation_state.json b/tests/fixtures/home_by_bridge.boiler_wiring_installation_state.json new file mode 100644 index 0000000..c45fbf4 --- /dev/null +++ b/tests/fixtures/home_by_bridge.boiler_wiring_installation_state.json @@ -0,0 +1,18 @@ +{ + "state": "INSTALLATION_COMPLETED", + "deviceWiredToBoiler": { + "type": "RU02B", + "serialNo": "RUXXXXXXXXXX", + "thermInterfaceType": "OPENTHERM", + "connected": true, + "lastRequestTimestamp": "2024-12-28T10:36:47.533Z" + }, + "bridgeConnected": true, + "hotWaterZonePresent": false, + "boiler": { + "outputTemperature": { + "celsius": 38.01, + "timestamp": "2024-12-28T10:36:54.000Z" + } + } +} diff --git a/tests/test_my_tado.py b/tests/test_my_tado.py index d96ada2..c806055 100644 --- a/tests/test_my_tado.py +++ b/tests/test_my_tado.py @@ -6,7 +6,7 @@ from . import common -from PyTado.http import Http +from PyTado.http import Http, TadoRequest from PyTado.interface.api import Tado @@ -15,9 +15,7 @@ class TadoTestCase(unittest.TestCase): def setUp(self) -> None: super().setUp() - login_patch = mock.patch( - "PyTado.http.Http._login", return_value=(1, "foo") - ) + login_patch = mock.patch("PyTado.http.Http._login", return_value=(1, "foo")) get_me_patch = mock.patch("PyTado.interface.api.Tado.get_me") login_patch.start() get_me_patch.start() @@ -35,9 +33,7 @@ def test_home_set_to_manual_mode( with mock.patch( "PyTado.http.Http.request", return_value=json.loads( - common.load_fixture( - "tadov2.home_state.auto_supported.manual_mode.json" - ) + common.load_fixture("tadov2.home_state.auto_supported.manual_mode.json") ), ): self.tado_client.get_home_state() @@ -53,9 +49,7 @@ def test_home_already_set_to_auto_mode( with mock.patch( "PyTado.http.Http.request", return_value=json.loads( - common.load_fixture( - "tadov2.home_state.auto_supported.auto_mode.json" - ) + common.load_fixture("tadov2.home_state.auto_supported.auto_mode.json") ), ): self.tado_client.get_home_state() @@ -86,9 +80,60 @@ def test_get_running_times(self): with mock.patch( "PyTado.http.Http.request", return_value=json.loads(common.load_fixture("running_times.json")), - ): + ) as mock_request: running_times = self.tado_client.get_running_times("2023-08-01") - assert self.tado_client._http.request.called + mock_request.assert_called_once() + assert running_times["lastUpdated"] == "2023-08-05T19:50:21Z" assert running_times["runningTimes"][0]["zones"][0]["id"] == 1 + + def test_get_boiler_install_state(self): + with mock.patch( + "PyTado.http.Http.request", + return_value=json.loads( + common.load_fixture("home_by_bridge.boiler_wiring_installation_state.json") + ), + ) as mock_request: + boiler_temperature = self.tado_client.get_boiler_install_state( + "IB123456789", "authcode" + ) + + mock_request.assert_called_once() + + assert boiler_temperature["boiler"]["outputTemperature"]["celsius"] == 38.01 + + def test_get_boiler_max_output_temperature(self): + with mock.patch( + "PyTado.http.Http.request", + return_value=json.loads( + common.load_fixture("home_by_bridge.boiler_max_output_temperature.json") + ), + ) as mock_request: + boiler_temperature = self.tado_client.get_boiler_max_output_temperature( + "IB123456789", "authcode" + ) + + mock_request.assert_called_once() + + assert boiler_temperature["boilerMaxOutputTemperatureInCelsius"] == 50.0 + + def test_set_boiler_max_output_temperature(self): + with mock.patch( + "PyTado.http.Http.request", + return_value={"success": True}, + ) as mock_request: + response = self.tado_client.set_boiler_max_output_temperature( + "IB123456789", "authcode", 75 + ) + + mock_request.assert_called_once() + args, _ = mock_request.call_args + request: TadoRequest = args[0] + + self.assertEqual(request.command, "boilerMaxOutputTemperature") + self.assertEqual(request.action, "PUT") + self.assertEqual(request.payload, {"boilerMaxOutputTemperatureInCelsius": 75}) + + # Verify the response + self.assertTrue(response["success"]) From 71849f4c210675886ec5daada2010ed5511301d6 Mon Sep 17 00:00:00 2001 From: Malachi Soord Date: Sat, 28 Dec 2024 21:10:17 +0100 Subject: [PATCH 40/49] Support devices with serialNumber as their property (#124) --- PyTado/interface/api/hops_tado.py | 6 +- tests/fixtures/tadox/rooms_and_devices.json | 64 +++++++++++++++++++++ tests/test_hops_zone.py | 22 +++++++ 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/tadox/rooms_and_devices.json diff --git a/PyTado/interface/api/hops_tado.py b/PyTado/interface/api/hops_tado.py index 7251369..74fed39 100644 --- a/PyTado/interface/api/hops_tado.py +++ b/PyTado/interface/api/hops_tado.py @@ -72,9 +72,13 @@ def get_devices(self): devices = [device for room in rooms for device in room["devices"]] for device in devices: + serial_number = device.get("serialNo", device.get("serialNumber")) + if not serial_number: + continue + request = TadoXRequest() request.domain = Domain.DEVICES - request.device = device["serialNo"] + request.device = serial_number device.update(self._http.request(request)) if "otherDevices" in rooms_and_devices: diff --git a/tests/fixtures/tadox/rooms_and_devices.json b/tests/fixtures/tadox/rooms_and_devices.json new file mode 100644 index 0000000..077944c --- /dev/null +++ b/tests/fixtures/tadox/rooms_and_devices.json @@ -0,0 +1,64 @@ +{ + "otherDevices": [ + { + "connection": { + "state": "CONNECTED" + }, + "firmwareVersion": "245.1", + "serialNumber": "IB1234567890", + "type": "IB02" + } + ], + "rooms": [ + { + "deviceManualControlTermination": { + "durationInSeconds": null, + "type": "MANUAL" + }, + "devices": [ + { + "batteryState": "NORMAL", + "childLockEnabled": false, + "connection": { + "state": "CONNECTED" + }, + "firmwareVersion": "243.1", + "mountingState": "CALIBRATED", + "serialNumber": "VA1234567890", + "temperatureAsMeasured": 17.00, + "temperatureOffset": 0.0, + "type": "VA04" + } + ], + "roomId": 1, + "roomName": "Room 1", + "zoneControllerAssignable": false, + "zoneControllers": [] + }, + { + "deviceManualControlTermination": { + "durationInSeconds": null, + "type": "MANUAL" + }, + "devices": [ + { + "batteryState": "NORMAL", + "childLockEnabled": false, + "connection": { + "state": "CONNECTED" + }, + "firmwareVersion": "243.1", + "mountingState": "CALIBRATED", + "serialNumber": "VA1234567891", + "temperatureAsMeasured": 18.00, + "temperatureOffset": 0.0, + "type": "VA04" + } + ], + "roomId": 2, + "roomName": " Room 2", + "zoneControllerAssignable": false, + "zoneControllers": [] + } + ] + } diff --git a/tests/test_hops_zone.py b/tests/test_hops_zone.py index 8b7fbb8..acfb0f9 100644 --- a/tests/test_hops_zone.py +++ b/tests/test_hops_zone.py @@ -44,6 +44,17 @@ def check_get_state(zone_id): get_state_patch.start() self.addCleanup(get_state_patch.stop) + def set_get_devices_fixture(self, filename: str) -> None: + def get_devices(): + return json.loads(common.load_fixture(filename)) + + get_devices_patch = mock.patch( + "PyTado.interface.api.TadoX.get_devices", + side_effect=get_devices, + ) + get_devices_patch.start() + self.addCleanup(get_devices_patch.stop) + def test_tadox_heating_auto_mode(self): """Test general homes response.""" @@ -145,3 +156,14 @@ def test_tadox_heating_manual_off(self): assert mode.tado_mode is None assert mode.target_temp is None assert mode.zone_id == 1 + + def test_get_devices(self): + """ Test get_devices method """ + self.set_get_devices_fixture("tadox/rooms_and_devices.json") + + devices_and_rooms = self.tado_client.get_devices() + rooms = devices_and_rooms['rooms'] + assert len(rooms) == 2 + room_1 = rooms[0] + assert room_1['roomName'] == 'Room 1' + assert room_1['devices'][0]['serialNumber'] == 'VA1234567890' From ac56425625840e7bc843bd4f5d1d399438efc5ae Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sat, 28 Dec 2024 23:36:10 +0100 Subject: [PATCH 41/49] Add childlock support (#127) Co-authored-by: Wolfgang Malgadey --- .gitignore | 6 ++++++ PyTado/interface/api/hops_tado.py | 12 ++++++++++++ PyTado/interface/api/my_tado.py | 14 ++++++++++++++ PyTado/interface/interface.py | 5 +++++ README.md | 19 +++++++++++++++++++ examples/__init__.py | 1 + examples/example.py | 14 ++++++++++++++ 7 files changed, 71 insertions(+) create mode 100644 examples/__init__.py create mode 100644 examples/example.py diff --git a/.gitignore b/.gitignore index dbce267..ecd44d9 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,12 @@ instance/ # Sphinx documentation docs/_build/ +# Ruff cache +.ruff_cache + +# Example dev +/examples/example_dev.py + # PyBuilder .pybuilder/ target/ diff --git a/PyTado/interface/api/hops_tado.py b/PyTado/interface/api/hops_tado.py index 74fed39..2224f71 100644 --- a/PyTado/interface/api/hops_tado.py +++ b/PyTado/interface/api/hops_tado.py @@ -328,3 +328,15 @@ def set_temp_offset(self, device_id, offset=0, measure="celsius"): request.payload = {"temperatureOffset": offset} return self._http.request(request) + + def set_child_lock(self, device_id, child_lock): + """ " + Set and toggle the child lock on the device. + """ + + request = TadoXRequest() + request.command = f"roomsAndDevices/devices/{device_id}" + request.action = Action.CHANGE + request.payload = {"childLockEnabled": child_lock} + + self._http.request(request) diff --git a/PyTado/interface/api/my_tado.py b/PyTado/interface/api/my_tado.py index 4044b85..dd2b850 100644 --- a/PyTado/interface/api/my_tado.py +++ b/PyTado/interface/api/my_tado.py @@ -400,6 +400,20 @@ def change_presence(self, presence: Presence) -> None: self._http.request(request) + def set_child_lock(self, device_id, child_lock) -> None: + """ + Sets the child lock on a device + """ + + request = TadoRequest() + request.command = "childLock" + request.action = Action.CHANGE + request.device = device_id + request.domain = Domain.DEVICES + request.payload = {"childLockEnabled": child_lock} + + self._http.request(request) + def set_auto(self) -> None: """ Sets HomeState to AUTO diff --git a/PyTado/interface/interface.py b/PyTado/interface/interface.py index 52c3f53..d2079f0 100644 --- a/PyTado/interface/interface.py +++ b/PyTado/interface/interface.py @@ -77,6 +77,11 @@ def getZones(self): """Gets zones information. (deprecated)""" return self.get_zones() + @deprecated("set_child_lock") + def setChildLock(self, device_id, enabled): + """Set the child lock for a device""" + return self.set_child_lock(device_id, enabled) + @deprecated("get_zone_state") def getZoneState(self, zone): """Gets current state of Zone as a TadoZone object. (deprecated)""" diff --git a/README.md b/README.md index 2ef025a..83fc92d 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,25 @@ cause discomfort and inconvenience to others. >>> t = Tado('my@username.com', 'mypassword') >>> climate = t.get_climate(zone=1) +## Usage +```python +"""Example client for PyTado""" + +from PyTado.interface.interface import Tado + + +def main() -> None: + """Retrieve all zones, once successfully logged in""" + tado = Tado(username="mail@email.com", password="password") # nosec + zones = tado.get_zones() + print(zones) + + +if __name__ == "__main__": + main() +``` +Note: for developers, you can create an `example_dev.py` file in the root of the project to test your changes. This file will be ignored by the `.gitignore` file. + ## Contributing We are very open to the community's contributions - be it a quick fix of a typo, or a completely new feature! diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..d054ccc --- /dev/null +++ b/examples/__init__.py @@ -0,0 +1 @@ +"""Example(s) for PyTado""" diff --git a/examples/example.py b/examples/example.py new file mode 100644 index 0000000..f4f2e66 --- /dev/null +++ b/examples/example.py @@ -0,0 +1,14 @@ +"""Example client for PyTado""" + +from PyTado.interface.interface import Tado + + +def main() -> None: + """Retrieve all zones, once successfully logged in""" + tado = Tado(username="mail@email.com", password="password") # nosec + zones = tado.get_zones() + print(zones) + + +if __name__ == "__main__": + main() From 638653db642b8fe32d92d2e11e6c82e13f9babe9 Mon Sep 17 00:00:00 2001 From: Moritz <88738242+Moritz-Schmidt@users.noreply.github.com> Date: Sun, 29 Dec 2024 12:49:42 +0100 Subject: [PATCH 42/49] fix for autopep8 (#135) --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a5445df..ecfe388 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,8 +26,8 @@ repos: exclude: tests/ args: ["--profile", "black"] -- repo: https://github.com/pre-commit/mirrors-autopep8 - rev: v2.0.4 +- repo: https://github.com/hhatto/autopep8 + rev: v2.3.1 hooks: - id: autopep8 exclude: tests/ From 51860cd0c45d7f3e65aa18cb6323491588cad9b6 Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Mon, 30 Dec 2024 18:47:26 +0100 Subject: [PATCH 43/49] Fixed devcontainer (#136) --- .devcontainer/devcontainer.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 7d94b07..32a49a7 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -61,10 +61,8 @@ "name": "PyTado", "postCreateCommand": "python3 -m venv venv && . venv/bin/activate && pip install --upgrade pip && pip install -e '.[all]' && pre-commit install", "updateContentCommand": "python3 -m venv venv && . venv/bin/activate && pip install --upgrade pip && pip install -e '.[all]' && pre-commit install", - "runArgs": [ - "--userns=keep-id" - ], "containerUser": "vscode", + "remoteUser": "vscode", "updateRemoteUserUID": true, "containerEnv": { "HOME": "/home/vscode" From a81be2a6f33f037681c0353f4e63665edcb42a66 Mon Sep 17 00:00:00 2001 From: Malachi Soord Date: Mon, 30 Dec 2024 20:14:51 +0100 Subject: [PATCH 44/49] Bootstrap improvements (#137) --- .devcontainer/devcontainer.json | 4 ++-- .envrc | 4 ++++ .github/workflows/pre-commit.yml | 2 -- .github/workflows/publish-to-pypi.yml | 2 -- .github/workflows/report-test-results.yml | 4 +--- .gitignore | 2 ++ .python-version | 1 + README.md | 13 ++++++++++++- examples/example.py | 11 ++++++++++- scripts/bootstrap | 9 +++++++++ 10 files changed, 41 insertions(+), 11 deletions(-) create mode 100644 .envrc create mode 100644 .python-version create mode 100755 scripts/bootstrap diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 32a49a7..2d26ee4 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -59,8 +59,8 @@ }, "image": "mcr.microsoft.com/vscode/devcontainers/python:3.12", "name": "PyTado", - "postCreateCommand": "python3 -m venv venv && . venv/bin/activate && pip install --upgrade pip && pip install -e '.[all]' && pre-commit install", - "updateContentCommand": "python3 -m venv venv && . venv/bin/activate && pip install --upgrade pip && pip install -e '.[all]' && pre-commit install", + "postStartCommand": "bash scripts/bootstrap", + "updateContentCommand": "bash scripts/bootstrap", "containerUser": "vscode", "remoteUser": "vscode", "updateRemoteUserUID": true, diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..0261b0b --- /dev/null +++ b/.envrc @@ -0,0 +1,4 @@ +export VIRTUAL_ENV=."venv" +layout python + +[[ -f .envrc.private ]] && source_env .envrc.private diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 3756ff4..2cb429d 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -17,8 +17,6 @@ jobs: - name: Set up Python 3.12 uses: actions/setup-python@v5 - with: - python-version: 3.12 - name: Install dependencies run: | diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 221a279..e39c2a8 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -20,8 +20,6 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 - with: - python-version: "3.x" - name: Install pypa/build run: >- python3 -m diff --git a/.github/workflows/report-test-results.yml b/.github/workflows/report-test-results.yml index 5096e1e..92f6e5a 100644 --- a/.github/workflows/report-test-results.yml +++ b/.github/workflows/report-test-results.yml @@ -17,10 +17,8 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python 3.12 + - name: Set up Python uses: actions/setup-python@v5 - with: - python-version: 3.12 - name: Install dependencies run: | diff --git a/.gitignore b/.gitignore index ecd44d9..065a75c 100644 --- a/.gitignore +++ b/.gitignore @@ -133,6 +133,8 @@ venv/ ENV/ env.bak/ venv.bak/ +.envrc.private +!/.envrc # Spyder project settings .spyderproject diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/README.md b/README.md index 83fc92d..d3b895a 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,18 @@ def main() -> None: if __name__ == "__main__": main() ``` -Note: for developers, you can create an `example_dev.py` file in the root of the project to test your changes. This file will be ignored by the `.gitignore` file. + +Note: For developers, there is an `example.py` script in `examples/` which is configured to fetch data from your account. + +You can easily inject your credentials leveraging a tool such as [direnv](https://direnv.net/) and creating a `.envrc.private` file in the root of the repo with the contents set to your Tado credentials. + +```aiignore +export TADO_USERNAME="username" +export TADO_PASSWORD="password" +``` + +You can then invoke `python examples/example.py`. + ## Contributing diff --git a/examples/example.py b/examples/example.py index f4f2e66..b52e5ef 100644 --- a/examples/example.py +++ b/examples/example.py @@ -1,11 +1,20 @@ """Example client for PyTado""" +import os +import sys + from PyTado.interface.interface import Tado +tado_username = os.getenv("TADO_USERNAME", "") +tado_password = os.getenv("TADO_PASSWORD", "") + +if len(tado_username) == 0 or len(tado_password) == 0: + sys.exit("TADO_USERNAME and TADO_PASSWORD must be set") + def main() -> None: """Retrieve all zones, once successfully logged in""" - tado = Tado(username="mail@email.com", password="password") # nosec + tado = Tado(username=tado_username, password=tado_password) # nosec zones = tado.get_zones() print(zones) diff --git a/scripts/bootstrap b/scripts/bootstrap new file mode 100755 index 0000000..a91c681 --- /dev/null +++ b/scripts/bootstrap @@ -0,0 +1,9 @@ +#!/bin/bash + +set -e + +python3 -m venv .venv +source .venv/bin/activate +pip install --upgrade pip +pip install -e '.[all]' +pre-commit install From c2310e6eafd3c26e9a00ed4b2f635dab479f1cdf Mon Sep 17 00:00:00 2001 From: Malachi Soord Date: Wed, 1 Jan 2025 17:58:27 +0100 Subject: [PATCH 45/49] Fix script naming (#138) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b6a6bbe..86e7de8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ all = [ ] [project.scripts] -pytado = "pytado.__main__:main" +pytado = "PyTado.__main__:main" [tool.setuptools] platforms = ["any"] From dbf22951c6b6a108863c0eb6864018816b1a04f4 Mon Sep 17 00:00:00 2001 From: Malachi Soord Date: Wed, 1 Jan 2025 17:59:11 +0100 Subject: [PATCH 46/49] Tidy GitHub action pipelines (#139) --- .github/workflows/lint-and-test-matrix.yml | 10 ++-------- .github/workflows/pre-commit.yml | 2 +- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/.github/workflows/lint-and-test-matrix.yml b/.github/workflows/lint-and-test-matrix.yml index c333899..81de53c 100644 --- a/.github/workflows/lint-and-test-matrix.yml +++ b/.github/workflows/lint-and-test-matrix.yml @@ -13,17 +13,11 @@ jobs: lint: runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.11", "3.12"] - steps: - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} + - name: Set up Python uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - name: Install dependencies run: | @@ -42,7 +36,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.11", "3.12"] + python-version: ["3.13", "3.12", "3.11"] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 2cb429d..b3353d0 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -15,7 +15,7 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python 3.12 + - name: Set up Python uses: actions/setup-python@v5 - name: Install dependencies From 5dbecafa87fb07343548e4dfcb9eb227916cb129 Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Wed, 1 Jan 2025 18:19:41 +0100 Subject: [PATCH 47/49] drop coverage and add codecov.io (#140) --- .github/workflows/lint-and-test-matrix.yml | 20 ++++---- .github/workflows/report-test-results.yml | 57 ---------------------- .gitignore | 1 + README.md | 1 + pyproject.toml | 2 +- 5 files changed, 14 insertions(+), 67 deletions(-) delete mode 100644 .github/workflows/report-test-results.yml diff --git a/.github/workflows/lint-and-test-matrix.yml b/.github/workflows/lint-and-test-matrix.yml index 81de53c..7b51e3f 100644 --- a/.github/workflows/lint-and-test-matrix.yml +++ b/.github/workflows/lint-and-test-matrix.yml @@ -51,15 +51,17 @@ jobs: python -m pip install --upgrade pip pip install -e '.[all]' - - name: Run Tests with Coverage + - name: Run Tests with coverage run: | - pip install coverage pytest pytest-cov - coverage run -m pytest --maxfail=1 --disable-warnings -q - coverage report -m - coverage html + pytest --cov --junitxml=junit.xml -o junit_family=legacy --cov-branch --cov-report=xml - - name: Upload coverage report - uses: actions/upload-artifact@v4 + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 with: - name: coverage-html-report-${{ matrix.python-version }} - path: coverage_html_report + token: ${{ secrets.CODECOV_TOKEN }} + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/report-test-results.yml b/.github/workflows/report-test-results.yml deleted file mode 100644 index 92f6e5a..0000000 --- a/.github/workflows/report-test-results.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Output test results - -permissions: - pull-requests: write - -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - -jobs: - report: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e '.[all]' - - - name: Run Tests with Coverage - run: | - pip install coverage pytest pytest-cov - coverage run -m pytest --maxfail=1 --disable-warnings -q - coverage report -m - coverage html - coverage xml - - - name: Code Coverage Report - uses: irongut/CodeCoverageSummary@v1.3.0 - with: - filename: coverage.xml - badge: true - fail_below_min: true - format: markdown - hide_branch_rate: false - hide_complexity: false - indicators: true - output: both - thresholds: '60 80' - - - name: Add Coverage PR Comment - uses: marocchino/sticky-pull-request-comment@v2 - if: github.event_name == 'pull_request' - with: - recreate: true - path: code-coverage-results.md - - - name: Output code coverage results to GitHub step summary - run: cat code-coverage-results.md >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index 065a75c..fe10763 100644 --- a/.gitignore +++ b/.gitignore @@ -168,3 +168,4 @@ cython_debug/ #.idea/ .DS_Store +junit.xml diff --git a/README.md b/README.md index d3b895a..7b80a4f 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![Linting and testing](https://github.com/wmalgadey/PyTado/actions/workflows/lint-and-test-matrix.yml/badge.svg)](https://github.com/wmalgadey/PyTado/actions/workflows/lint-and-test-matrix.yml) [![Build and deploy to pypi](https://github.com/wmalgadey/PyTado/actions/workflows/publish-to-pypi.yml/badge.svg?event=release)](https://github.com/wmalgadey/PyTado/actions/workflows/publish-to-pypi.yml) [![PyPI version](https://badge.fury.io/py/python-tado.svg)](https://badge.fury.io/py/python-tado) +[![codecov](https://codecov.io/github/wmalgadey/PyTado/graph/badge.svg?token=14TT00IWJI)](https://codecov.io/github/wmalgadey/PyTado) [![Open in Dev Containers][devcontainer-shield]][devcontainer] PyTado is a Python module implementing an interface to the Tado web API. It allows a user to interact with their diff --git a/pyproject.toml b/pyproject.toml index 86e7de8..70e2ce0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ classifiers = [ GitHub = "https://github.com/wmalgadey/PyTado" [project.optional-dependencies] -dev = ["black>=24.3", "pre-commit", "pytype", "pylint", "types-requests", "requests", "responses", "coverage", "pytest", "pytest-cov"] +dev = ["black>=24.3", "pre-commit", "pytype", "pylint", "types-requests", "requests", "responses", "pytest", "pytest-cov"] all = [ "python-tado[dev]", From bfab483865b872773d03424a534162f1d5646188 Mon Sep 17 00:00:00 2001 From: Malachi Soord Date: Wed, 1 Jan 2025 20:01:20 +0100 Subject: [PATCH 48/49] Add main test foundations (#141) --- tests/test_main.py | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 tests/test_main.py diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..12d5efc --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,9 @@ +import pytest + +from PyTado.__main__ import main + +def test_entry_point_no_args(): + with pytest.raises(SystemExit) as excinfo: + main() + + assert excinfo.value.code == 2 From 72727219645635ff2ecb8cbb0dde8d2eae38eb6f Mon Sep 17 00:00:00 2001 From: Filippo Barba Date: Fri, 3 Jan 2025 21:35:52 +0100 Subject: [PATCH 49/49] fix: pre-commits --- PyTado/interface/api/hops_tado.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PyTado/interface/api/hops_tado.py b/PyTado/interface/api/hops_tado.py index 49fa919..a058114 100644 --- a/PyTado/interface/api/hops_tado.py +++ b/PyTado/interface/api/hops_tado.py @@ -295,7 +295,7 @@ def set_open_window(self, zone): request.action = Action.SET return self._http.request(request) - + def reset_open_window(self, zone): """ Sets the window in zone to closed