├── .nojekyll ├── tests ├── __init__.py ├── fixtures │ ├── tadox │ │ ├── hops_tado_homes_quickActions_boost_boostableZones.json │ │ ├── hops_tado_homes_features.json │ │ ├── hops_tado_homes_programmer_domesticHotWater.json │ │ ├── hops_tado_homes_heatPump.json │ │ └── rooms_and_devices.json │ ├── home_by_bridge.boiler_max_output_temperature.json │ ├── tadov2.home_state.auto_not_supported.json │ ├── tadov2.home_state.auto_supported.auto_mode.json │ ├── tadov2.home_state.auto_supported.manual_mode.json │ ├── home_1234 │ │ ├── tadox.hops_homes.json │ │ ├── tadox.heating.boost_mode.json │ │ ├── tadox.heating.auto_mode.json │ │ ├── tadox.heating.manual_mode.json │ │ ├── tadox.heating.manual_off.json │ │ ├── tadov2.my_api_v2_home_state.json │ │ ├── tadox.my_api_v2_home_state.json │ │ ├── my_api_v2_me.json │ │ └── tadox.schedule.json │ ├── set_flow_temperature_optimization_issue_143.json │ ├── flow_temperature_optimization.json │ ├── home_by_bridge.boiler_wiring_installation_state.json │ ├── tadov2.water_heater.auto_mode.json │ ├── zone_with_swing_capabilities.json │ ├── tadov2.water_heater.off_mode.json │ ├── smartac3.smart_mode.json │ ├── tadov2.water_heater.manual_mode.json │ ├── smartac3.hvac_off.json │ ├── smartac3.manual_off.json │ ├── smartac3.turning_off.json │ ├── smartac3.dry_mode.json │ ├── smartac3.auto_mode.json │ ├── ac_issue_32294.heat_mode.json │ ├── tadov2.heating.auto_mode.json │ ├── smartac3.fan_mode.json │ ├── smartac3.cool_mode.json │ ├── smartac3.heat_mode.json │ ├── smartac3.with_swing.json │ ├── smartac3.offline.json │ ├── tadov2.heating.off_mode.json │ ├── tadov2.heating.manual_mode.json │ ├── hvac_action_heat.json │ ├── my_api_issue_88.termination_condition.json │ └── running_times.json ├── common.py ├── test_main.py ├── test_tado_initializer.py ├── test_tado_interface.py └── test_my_tado.py ├── .nvmrc ├── public ├── img │ └── .gitkeep └── css │ ├── message-box.css │ ├── prism-diff.css │ └── index.css ├── .python-version ├── PyTado ├── models │ ├── common │ │ ├── __init__.py │ │ └── schedule.py │ ├── pre_line_x │ │ ├── home.py │ │ ├── schedule.py │ │ ├── __init__.py │ │ ├── flow_temperature_optimization.py │ │ ├── boiler.py │ │ ├── device.py │ │ └── zone.py │ ├── line_x │ │ ├── installation.py │ │ ├── __init__.py │ │ ├── schedule.py │ │ ├── device.py │ │ └── room.py │ ├── __init__.py │ ├── return_models.py │ ├── util.py │ ├── historic.py │ └── home.py ├── interface │ ├── __init__.py │ ├── api │ │ └── __init__.py │ └── interface.py ├── zone │ └── __init__.py ├── __init__.py ├── factory │ ├── __init__.py │ └── initializer.py ├── exceptions.py ├── logger.py ├── const.py ├── __main__.py └── types.py ├── examples ├── __init__.py └── example.py ├── pytest.ini ├── Manifest.in ├── docs ├── content │ ├── content.11tydata.js │ ├── index.md │ ├── contribution.md │ └── 404.md ├── _includes │ └── layouts │ │ ├── home.njk │ │ ├── page.njk │ │ └── base.njk ├── _data │ ├── eleventyDataSchema.js │ └── metadata.js └── _config │ └── filters.js ├── .envrc ├── screenshots ├── tado-device-flow-0.png ├── tado-device-flow-1.png └── tado-device-flow-2.png ├── .pylintrc ├── scripts ├── bootstrap └── refresh.sh ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── AUTHORS ├── .github ├── dependabot.yml ├── workflows │ ├── publish-to-pypi.yml │ ├── pre-commit.yml │ ├── deploy-11ty-pages.yml │ ├── test.yml │ └── codeql-advanced.yml ├── pull_request_template.md └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── .coveragerc ├── package.json ├── SECURITY.md ├── .pre-commit-config.yaml ├── pyproject.toml ├── .devcontainer └── devcontainer.json ├── CONTRIBUTION.md ├── eleventy.config.js ├── .gitignore ├── generate_api_docs.py └── README.md /.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.20.4 2 | -------------------------------------------------------------------------------- /public/img/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /PyTado/models/common/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | """Example(s) for PyTado""" 2 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --disable-socket 3 | -------------------------------------------------------------------------------- /Manifest.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | include requirements.txt 4 | -------------------------------------------------------------------------------- /docs/content/content.11tydata.js: -------------------------------------------------------------------------------- 1 | export default { 2 | layout: "layouts/page.njk", 3 | }; 4 | -------------------------------------------------------------------------------- /docs/_includes/layouts/home.njk: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/base.njk 3 | --- 4 | 5 | {{ content | safe }} 6 | -------------------------------------------------------------------------------- /docs/_includes/layouts/page.njk: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/base.njk 3 | --- 4 | 5 | {{ content | safe }} 6 | -------------------------------------------------------------------------------- /docs/content/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layouts/home.njk 3 | --- 4 | 5 | {% renderFile "./README.md" %} 6 | -------------------------------------------------------------------------------- /tests/fixtures/tadox/hops_tado_homes_quickActions_boost_boostableZones.json: -------------------------------------------------------------------------------- 1 | { 2 | "zones": [] 3 | } 4 | -------------------------------------------------------------------------------- /tests/fixtures/home_by_bridge.boiler_max_output_temperature.json: -------------------------------------------------------------------------------- 1 | {"boilerMaxOutputTemperatureInCelsius":50} 2 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | export VIRTUAL_ENV=."venv" 2 | layout python 3 | 4 | [[ -f .envrc.private ]] && source_env .envrc.private 5 | -------------------------------------------------------------------------------- /screenshots/tado-device-flow-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmalgadey/PyTado/HEAD/screenshots/tado-device-flow-0.png -------------------------------------------------------------------------------- /screenshots/tado-device-flow-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmalgadey/PyTado/HEAD/screenshots/tado-device-flow-1.png -------------------------------------------------------------------------------- /screenshots/tado-device-flow-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmalgadey/PyTado/HEAD/screenshots/tado-device-flow-2.png -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | ignore-patterns= 3 | max-line-length=100 4 | 5 | [MESSAGES CONTROL] 6 | disable=protected-access 7 | -------------------------------------------------------------------------------- /tests/fixtures/tadov2.home_state.auto_not_supported.json: -------------------------------------------------------------------------------- 1 | { 2 | "presence": "HOME", 3 | "presenceLocked": true 4 | } 5 | -------------------------------------------------------------------------------- /tests/fixtures/tadov2.home_state.auto_supported.auto_mode.json: -------------------------------------------------------------------------------- 1 | { 2 | "presence": "HOME", 3 | "presenceLocked": false 4 | } 5 | -------------------------------------------------------------------------------- /PyTado/interface/__init__.py: -------------------------------------------------------------------------------- 1 | """Abstraction layer for API implementation.""" 2 | 3 | from .interface import Tado 4 | 5 | __all__ = ["Tado"] 6 | -------------------------------------------------------------------------------- /scripts/bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | python3 -m venv .venv 6 | source .venv/bin/activate 7 | poetry install 8 | poetry run pre-commit install 9 | -------------------------------------------------------------------------------- /tests/fixtures/tadox/hops_tado_homes_features.json: -------------------------------------------------------------------------------- 1 | { 2 | "availableFeatures": [ 3 | "geofencing", 4 | "openWindowDetection" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /docs/content/contribution.md: -------------------------------------------------------------------------------- 1 | ---js 2 | const eleventyNavigation = { 3 | key: "Contribute", 4 | order: 2 5 | }; 6 | --- 7 | {% renderFile "./CONTRIBUTION.md" %} 8 | -------------------------------------------------------------------------------- /tests/fixtures/tadox/hops_tado_homes_programmer_domesticHotWater.json: -------------------------------------------------------------------------------- 1 | { 2 | "isDomesticHotWaterCapable": false, 3 | "domesticHotWaterInterface": "NONE" 4 | } 5 | -------------------------------------------------------------------------------- /tests/fixtures/tadov2.home_state.auto_supported.manual_mode.json: -------------------------------------------------------------------------------- 1 | { 2 | "presence": "HOME", 3 | "presenceLocked": true, 4 | "showSwitchToAutoGeofencingButton": true 5 | } 6 | -------------------------------------------------------------------------------- /PyTado/models/pre_line_x/home.py: -------------------------------------------------------------------------------- 1 | from PyTado.models.util import Base 2 | 3 | 4 | class HeatingCircuit(Base): 5 | number: int 6 | driverSerialNo: str 7 | driverShortSerialNo: str 8 | -------------------------------------------------------------------------------- /PyTado/zone/__init__.py: -------------------------------------------------------------------------------- 1 | """Zone/Room data structures for all API interfaces.""" 2 | 3 | from .hops_zone import TadoRoom 4 | from .my_zone import TadoZone 5 | 6 | __all__ = ["TadoZone", "TadoRoom"] 7 | -------------------------------------------------------------------------------- /tests/fixtures/home_1234/tadox.hops_homes.json: -------------------------------------------------------------------------------- 1 | { 2 | "roomCount": 2, 3 | "isHeatSourceInstalled": false, 4 | "isHeatPumpInstalled": false, 5 | "supportsFlowTemperatureOptimization": false 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-python.black-formatter", 4 | "github.vscode-github-actions", 5 | "ms-python.pylint", 6 | "ms-python.flake8" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /PyTado/interface/api/__init__.py: -------------------------------------------------------------------------------- 1 | """Module for all API interfaces.""" 2 | 3 | from .base_tado import TadoBase 4 | from .hops_tado import TadoX 5 | from .my_tado import Tado 6 | 7 | __all__ = ["Tado", "TadoX", "TadoBase"] 8 | -------------------------------------------------------------------------------- /PyTado/models/line_x/installation.py: -------------------------------------------------------------------------------- 1 | from PyTado.models.util import Base 2 | 3 | 4 | class Installation(Base): 5 | """Installation model represents the installation object.""" 6 | 7 | id: int 8 | name: str 9 | -------------------------------------------------------------------------------- /PyTado/models/__init__.py: -------------------------------------------------------------------------------- 1 | from PyTado.models.historic import Historic 2 | from PyTado.models.return_models import Climate, SuccessResult 3 | 4 | __all__ = [ 5 | "Climate", 6 | "Historic", 7 | "SuccessResult", 8 | ] 9 | -------------------------------------------------------------------------------- /PyTado/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import PackageNotFoundError, version 2 | 3 | try: 4 | __version__ = version("python-tado") 5 | except PackageNotFoundError: 6 | __version__ = "test" # happens when running as pre-commit hook 7 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Author: Chris Jewell 2 | Modified: Gareth Jeanne 3 | Wolfgang Malgadey 4 | Matt Clayton 5 | Karl Beecken 6 | -------------------------------------------------------------------------------- /PyTado/factory/__init__.py: -------------------------------------------------------------------------------- 1 | """Factory method for easy initialization.""" 2 | 3 | from ..interface.api.hops_tado import TadoX 4 | from ..interface.api.my_tado import Tado 5 | from .initializer import TadoClientInitializer 6 | 7 | __all__ = ["Tado", "TadoX", "TadoClientInitializer"] 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" # Location of your Python project 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" # Location of your GitHub Actions workflows 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Python Debugger: Example DEV", 6 | "type": "debugpy", 7 | "request": "launch", 8 | "program": "${workspaceFolder}/examples/example_dev.py", 9 | "console": "integratedTerminal" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /docs/_data/eleventyDataSchema.js: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { fromZodError } from 'zod-validation-error'; 3 | 4 | export default function(data) { 5 | // Draft content, validate `draft` front matter 6 | let result = z.object({ 7 | draft: z.boolean().or(z.undefined()), 8 | }).safeParse(data); 9 | 10 | if(result.error) { 11 | throw fromZodError(result.error); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tests/fixtures/set_flow_temperature_optimization_issue_143.json: -------------------------------------------------------------------------------- 1 | { 2 | "hasMultipleBoilerControlDevices":false, 3 | "maxFlowTemperature":50, 4 | "maxFlowTemperatureConstraints":{ 5 | "min":30, 6 | "max":80 7 | }, 8 | "autoAdaptation":{ 9 | "enabled":false, 10 | "maxFlowTemperature":null 11 | }, 12 | "openThermDeviceSerialNumber":"" 13 | } 14 | -------------------------------------------------------------------------------- /examples/example.py: -------------------------------------------------------------------------------- 1 | """Example client for PyTado""" 2 | 3 | from PyTado.factory import TadoClientInitializer 4 | 5 | 6 | def main() -> None: 7 | """Retrieve all zones, once successfully logged in""" 8 | tado = TadoClientInitializer().authenticate_and_get_client() 9 | 10 | zones = tado.get_zones() 11 | 12 | print(zones) 13 | 14 | 15 | if __name__ == "__main__": 16 | main() 17 | -------------------------------------------------------------------------------- /tests/fixtures/flow_temperature_optimization.json: -------------------------------------------------------------------------------- 1 | { 2 | "hasMultipleBoilerControlDevices": false, 3 | "maxFlowTemperature": 55, 4 | "maxFlowTemperatureConstraints": { 5 | "min": 30, 6 | "max": 80 7 | }, 8 | "autoAdaptation": { 9 | "enabled": true, 10 | "maxFlowTemperature": 55 11 | }, 12 | "openThermDeviceSerialNumber": "RU1234567890" 13 | } 14 | -------------------------------------------------------------------------------- /public/css/message-box.css: -------------------------------------------------------------------------------- 1 | /* Message Box */ 2 | .message-box { 3 | --color-message-box: #ffc; 4 | 5 | display: block; 6 | background-color: var(--color-message-box); 7 | color: var(--color-gray-90); 8 | padding: 1em 0.625em; /* 16px 10px /16 */ 9 | } 10 | .message-box ol { 11 | margin-top: 0; 12 | } 13 | 14 | @media (prefers-color-scheme: dark) { 15 | .message-box { 16 | --color-message-box: #082840; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /PyTado/models/line_x/__init__.py: -------------------------------------------------------------------------------- 1 | from PyTado.models.line_x.device import Device, DevicesRooms 2 | from PyTado.models.line_x.installation import Installation 3 | from PyTado.models.line_x.room import RoomState, Setting 4 | from PyTado.models.line_x.schedule import Schedule, SetSchedule 5 | 6 | __all__ = [ 7 | "Device", 8 | "DevicesRooms", 9 | "RoomState", 10 | "Schedule", 11 | "SetSchedule", 12 | "Setting", 13 | "Installation", 14 | ] 15 | -------------------------------------------------------------------------------- /docs/content/404.md: -------------------------------------------------------------------------------- 1 | --- 2 | permalink: 404.html 3 | eleventyExcludeFromCollections: true 4 | --- 5 | # Content not found. 6 | 7 | Go home. 8 | 9 | 19 | -------------------------------------------------------------------------------- /PyTado/models/pre_line_x/schedule.py: -------------------------------------------------------------------------------- 1 | from typing import List, TypeAlias 2 | 3 | from pydantic import TypeAdapter 4 | 5 | from PyTado.models.common.schedule import ScheduleElement 6 | from PyTado.models.util import Base 7 | 8 | 9 | class TempValue(Base): 10 | """TempValue model represents the temperature value.""" 11 | 12 | celsius: float 13 | fahrenheit: float 14 | 15 | 16 | Schedule: TypeAlias = ScheduleElement[TempValue] 17 | Schedules = TypeAdapter(List[Schedule]) 18 | -------------------------------------------------------------------------------- /docs/_data/metadata.js: -------------------------------------------------------------------------------- 1 | export default { 2 | title: "PyTado -- Pythonize your central heating", 3 | url: "https://https://wmalgadey.github.io/PyTado/", 4 | language: "en", 5 | description: "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.", 6 | author: { 7 | name: "Wolfgang Malgadey", 8 | email: "wolfgang@malgadeey.de" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /scripts/refresh.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | exit_on_error () { 4 | echo Executing: $cmd 5 | echo `$cmd` 6 | if [ $? -eq 0 ] 7 | then 8 | return 0 9 | else 10 | echo "Failed to execute $cmd" >&2 11 | exit 1 12 | fi 13 | } 14 | 15 | cmd="git stash -m WIP" 16 | exit_on_error 17 | cmd="git checkout master" 18 | exit_on_error 19 | cmd="git fetch upstream" 20 | exit_on_error 21 | cmd="git reset --hard upstream/master" 22 | exit_on_error 23 | cmd="git push" 24 | exit_on_error 25 | cmd="git stash pop" 26 | exit_on_error 27 | 28 | poetry install 29 | -------------------------------------------------------------------------------- /tests/fixtures/home_by_bridge.boiler_wiring_installation_state.json: -------------------------------------------------------------------------------- 1 | { 2 | "state": "INSTALLATION_COMPLETED", 3 | "deviceWiredToBoiler": { 4 | "type": "RU02B", 5 | "serialNo": "RUXXXXXXXXXX", 6 | "thermInterfaceType": "OPENTHERM", 7 | "connected": true, 8 | "lastRequestTimestamp": "2024-12-28T10:36:47.533Z" 9 | }, 10 | "bridgeConnected": true, 11 | "hotWaterZonePresent": false, 12 | "boiler": { 13 | "outputTemperature": { 14 | "celsius": 38.01, 15 | "timestamp": "2024-12-28T10:36:54.000Z" 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /PyTado/models/pre_line_x/__init__.py: -------------------------------------------------------------------------------- 1 | from PyTado.models.pre_line_x.device import Device 2 | from PyTado.models.pre_line_x.schedule import Schedule 3 | from PyTado.models.pre_line_x.zone import ( 4 | Capabilities, 5 | Setting, 6 | TemperatureCapabilitiesValues, 7 | TemperatureCapability, 8 | Zone, 9 | ZoneOverlayDefault, 10 | ZoneState, 11 | ) 12 | 13 | __all__ = [ 14 | "Device", 15 | "Zone", 16 | "ZoneState", 17 | "Schedule", 18 | "ZoneOverlayDefault", 19 | "Capabilities", 20 | "TemperatureCapabilitiesValues", 21 | "TemperatureCapability", 22 | "Setting", 23 | ] 24 | -------------------------------------------------------------------------------- /PyTado/exceptions.py: -------------------------------------------------------------------------------- 1 | """Tado exceptions.""" 2 | 3 | 4 | class TadoException(Exception): 5 | """Base exception class for Tado.""" 6 | 7 | 8 | class TadoNotSupportedException(TadoException): 9 | """Exception to indicate a requested action is not supported by Tado.""" 10 | 11 | 12 | class TadoCredentialsException(TadoException): 13 | """Exception to indicate something with credentials""" 14 | 15 | 16 | class TadoNoCredentialsException(TadoCredentialsException): 17 | """Exception to indicate missing credentials""" 18 | 19 | 20 | class TadoWrongCredentialsException(TadoCredentialsException): 21 | """Exception to indicate wrong credentials""" 22 | -------------------------------------------------------------------------------- /PyTado/models/return_models.py: -------------------------------------------------------------------------------- 1 | from PyTado.models.util import Base 2 | 3 | 4 | class Climate(Base): 5 | """ 6 | Climate model represents the climate of a room. 7 | temperature is in Celsius and humidity is in percent 8 | """ 9 | 10 | temperature: float 11 | humidity: float 12 | 13 | 14 | class TemperatureOffset(Base): 15 | """ 16 | TemperatureOffset model represents the temperature offset in both Celsius and Fahrenheit. 17 | """ 18 | 19 | celsius: float 20 | fahrenheit: float 21 | 22 | 23 | class SuccessResult(Base): 24 | """ 25 | Model representing the result of a set operation. 26 | """ 27 | 28 | success: bool 29 | -------------------------------------------------------------------------------- /tests/fixtures/home_1234/tadox.heating.boost_mode.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 1, 3 | "name": "Room 1", 4 | "sensorDataPoints": { 5 | "insideTemperature": { "value": 19.92 }, 6 | "humidity": { "percentage": 45 } 7 | }, 8 | "setting": { "power": "ON", "temperature": null }, 9 | "manualControlTermination": null, 10 | "boostMode": { "projectedExpiry": "2025-04-08T19:22:40Z", "type": "TIMER" }, 11 | "heatingPower": { "percentage": 100 }, 12 | "connection": { "state": "CONNECTED" }, 13 | "openWindow": null, 14 | "nextScheduleChange": { 15 | "start": "2025-04-08T20:00:00Z", 16 | "setting": { "power": "ON", "temperature": { "value": 18.0 } } 17 | }, 18 | "nextTimeBlock": { "start": "2025-04-08T20:00:00Z" }, 19 | "balanceControl": null 20 | } 21 | -------------------------------------------------------------------------------- /PyTado/logger.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file contains anything related to logging 3 | """ 4 | 5 | import logging 6 | 7 | 8 | class Logger(logging.Logger): 9 | """ 10 | This class provides a custom logger without masking sensitive information. 11 | """ 12 | 13 | class SimpleFormatter(logging.Formatter): 14 | """ 15 | Simple formatter that does not remove any information in logs. 16 | """ 17 | 18 | def __init__(self, name: str, level: int = logging.NOTSET) -> None: 19 | super().__init__(name) 20 | log_sh = logging.StreamHandler() 21 | log_fmt = self.SimpleFormatter(fmt="%(name)s :: %(levelname)-8s :: %(message)s") 22 | log_sh.setFormatter(log_fmt) 23 | self.addHandler(log_sh) 24 | self.setLevel(level) 25 | -------------------------------------------------------------------------------- /tests/fixtures/tadov2.water_heater.auto_mode.json: -------------------------------------------------------------------------------- 1 | { 2 | "tadoMode": "HOME", 3 | "geolocationOverride": false, 4 | "geolocationOverrideDisableTime": null, 5 | "preparation": null, 6 | "setting": { 7 | "type": "HOT_WATER", 8 | "power": "ON", 9 | "temperature": { 10 | "celsius": 65.00, 11 | "fahrenheit": 149.00 12 | } 13 | }, 14 | "overlayType": null, 15 | "overlay": null, 16 | "openWindow": null, 17 | "nextScheduleChange": { 18 | "start": "2020-03-10T22:00:00Z", 19 | "setting": { 20 | "type": "HOT_WATER", 21 | "power": "OFF", 22 | "temperature": null 23 | } 24 | }, 25 | "nextTimeBlock": { 26 | "start": "2020-03-10T22:00:00.000Z" 27 | }, 28 | "link": { 29 | "state": "ONLINE" 30 | }, 31 | "activityDataPoints": {}, 32 | "sensorDataPoints": {} 33 | } 34 | -------------------------------------------------------------------------------- /PyTado/models/line_x/schedule.py: -------------------------------------------------------------------------------- 1 | from PyTado.models.common.schedule import ScheduleElement 2 | from PyTado.models.util import Base 3 | from PyTado.types import DayType 4 | 5 | 6 | class ScheduleRoom(Base): 7 | """ScheduleRoom model represents the schedule room.""" 8 | 9 | id: int 10 | name: str 11 | 12 | 13 | class TempValue(Base): 14 | """TempValue model represents the temperature value.""" 15 | 16 | value: float 17 | 18 | 19 | class Schedule(Base): 20 | """Schedule model represents the schedule of a zone.""" 21 | 22 | room: ScheduleRoom 23 | otherRooms: list[ScheduleRoom] 24 | schedule: list[ScheduleElement[TempValue]] 25 | 26 | 27 | class SetSchedule(Base): 28 | """SetSchedule model represents the schedule of a zone for set_schedule().""" 29 | 30 | day_type: DayType 31 | day_schedule: list[ScheduleElement[TempValue]] 32 | -------------------------------------------------------------------------------- /PyTado/models/pre_line_x/flow_temperature_optimization.py: -------------------------------------------------------------------------------- 1 | """ 2 | { 3 | "hasMultipleBoilerControlDevices":false, 4 | "maxFlowTemperature":50, 5 | "maxFlowTemperatureConstraints":{ 6 | "min":30, 7 | "max":80 8 | }, 9 | "autoAdaptation":{ 10 | "enabled":false, 11 | "maxFlowTemperature":null 12 | }, 13 | "openThermDeviceSerialNumber":"" 14 | } 15 | """ 16 | from PyTado.models.util import Base 17 | 18 | 19 | class MaxFlowTemperatureContraints(Base): 20 | min: float 21 | max: float 22 | 23 | 24 | class AutoAdaptation(Base): 25 | enabled: bool 26 | max_flow_temperature: float | None 27 | 28 | 29 | class FlowTemperatureOptimization(Base): 30 | has_multiple_boiler_control_devices: bool 31 | max_flow_temperature: float 32 | max_flow_temperature_constraints: MaxFlowTemperatureContraints 33 | auto_adaptation: AutoAdaptation 34 | open_therm_device_serial_number: str 35 | -------------------------------------------------------------------------------- /tests/fixtures/zone_with_swing_capabilities.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "AIR_CONDITIONING", 3 | "AUTO": { 4 | "fanSpeeds": ["AUTO", "HIGH", "MIDDLE", "LOW"], 5 | "swings": ["OFF", "ON"] 6 | }, 7 | "COOL": { 8 | "temperatures": { 9 | "celsius": { 10 | "min": 18, 11 | "max": 30, 12 | "step": 1.0 13 | }, 14 | "fahrenheit": { 15 | "min": 64, 16 | "max": 86, 17 | "step": 1.0 18 | } 19 | }, 20 | "fanSpeeds": ["AUTO", "HIGH", "MIDDLE", "LOW"], 21 | "swings": ["OFF", "ON"] 22 | }, 23 | "DRY": { 24 | "swings": ["OFF", "ON"] 25 | }, 26 | "FAN": { 27 | "fanSpeeds": ["AUTO", "HIGH", "MIDDLE", "LOW"], 28 | "swings": ["OFF", "ON"] 29 | }, 30 | "HEAT": { 31 | "temperatures": { 32 | "celsius": { 33 | "min": 16, 34 | "max": 30, 35 | "step": 1.0 36 | }, 37 | "fahrenheit": { 38 | "min": 61, 39 | "max": 86, 40 | "step": 1.0 41 | } 42 | }, 43 | "fanSpeeds": ["AUTO", "HIGH", "MIDDLE", "LOW"], 44 | "swings": ["OFF", "ON"] 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/fixtures/home_1234/tadox.heating.auto_mode.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 1, 3 | "name": "Room 1", 4 | "sensorDataPoints": { 5 | "insideTemperature": { 6 | "value": 24.0 7 | }, 8 | "humidity": { 9 | "percentage": 38 10 | } 11 | }, 12 | "setting": { 13 | "power": "ON", 14 | "temperature": { 15 | "value": 22.0 16 | } 17 | }, 18 | "manualControlTermination": null, 19 | "boostMode": null, 20 | "heatingPower": { 21 | "percentage": 100 22 | }, 23 | "connection": { 24 | "state": "CONNECTED" 25 | }, 26 | "openWindow": null, 27 | "nextScheduleChange": { 28 | "start": "2024-12-19T21:00:00Z", 29 | "setting": { 30 | "power": "ON", 31 | "temperature": { 32 | "value": 18.0 33 | } 34 | } 35 | }, 36 | "nextTimeBlock": { 37 | "start": "2024-12-19T21:00:00Z" 38 | }, 39 | "balanceControl": null 40 | } 41 | -------------------------------------------------------------------------------- /tests/fixtures/home_1234/tadox.heating.manual_mode.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 1, 3 | "name": "Room 1", 4 | "sensorDataPoints": { 5 | "insideTemperature": { 6 | "value": 24.07 7 | }, 8 | "humidity": { 9 | "percentage": 38 10 | } 11 | }, 12 | "setting": { 13 | "power": "ON", 14 | "temperature": { 15 | "value": 20.0 16 | } 17 | }, 18 | "manualControlTermination": { 19 | "type": "NEXT_TIME_BLOCK", 20 | "remainingTimeInSeconds": 4549, 21 | "projectedExpiry": "2024-12-19T21:00:00Z" 22 | }, 23 | "boostMode": null, 24 | "heatingPower": { 25 | "percentage": 0 26 | }, 27 | "connection": { 28 | "state": "CONNECTED" 29 | }, 30 | "openWindow": null, 31 | "nextScheduleChange": { 32 | "start": "2024-12-19T21:00:00Z", 33 | "setting": { 34 | "power": "ON", 35 | "temperature": { 36 | "value": 18.0 37 | } 38 | } 39 | }, 40 | "nextTimeBlock": { 41 | "start": "2024-12-19T21:00:00Z" 42 | }, 43 | "balanceControl": null 44 | } 45 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.linting.pylintEnabled": true, 3 | "cSpell.words": [ 4 | "Tado", 5 | "Pythonize", 6 | "pypi", 7 | "pytado" 8 | ], 9 | "python.linting.flake8Enabled": true, 10 | "python.linting.enabled": true, 11 | "python.testing.autoTestDiscoverOnSaveEnabled": true, 12 | "python.testing.pytestArgs": [ 13 | "." 14 | ], 15 | "python.testing.unittestEnabled": false, 16 | "python.testing.pytestEnabled": true, 17 | "editor.formatOnSave": true, 18 | "editor.formatOnPaste": true, 19 | "editor.defaultFormatter": "ms-python.black-formatter", 20 | "files.insertFinalNewline": true, 21 | "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", 22 | "python.terminal.shellIntegration.enabled": true, 23 | "pylint.args": [ 24 | "--max-line-length=100" 25 | ], 26 | "pylint.interpreter": [ 27 | ".venv/bin/python" 28 | ], 29 | "flake8.args": [ 30 | "--max-line-length=100" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /PyTado/models/pre_line_x/boiler.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from PyTado.models.home import Temperature 4 | from PyTado.models.util import Base 5 | 6 | 7 | class MaxOutputTemp(Base): 8 | """BoilerMaxOutputTemp model represents the maximum output temperature of the boiler.""" 9 | 10 | boiler_max_output_temperature_in_celsius: float 11 | 12 | 13 | class ThermDevice(Base): 14 | """ThermDevice model represents the device wired to a boiler.""" 15 | 16 | type: str 17 | serial_no: str 18 | therm_interface_type: str 19 | connected: bool 20 | last_request_timestamp: datetime 21 | 22 | 23 | class Boiler(Base): 24 | """Boiler model represents the boiler.""" 25 | 26 | output_temperature: Temperature 27 | 28 | 29 | class WiringInstallationState(Base): 30 | """WiringInstallationState model represents the wiring installation state of the boiler.""" 31 | 32 | state: str 33 | device_wired_to_boiler: ThermDevice 34 | bridge_connected: bool 35 | hot_water_zone_present: bool 36 | boiler: Boiler 37 | -------------------------------------------------------------------------------- /tests/fixtures/home_1234/tadox.heating.manual_off.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 1, 3 | "name": "Room 1", 4 | "sensorDataPoints": { 5 | "insideTemperature": { 6 | "value": 24.08 7 | }, 8 | "humidity": { 9 | "percentage": 38 10 | } 11 | }, 12 | "setting": { 13 | "power": "OFF", 14 | "temperature": null 15 | }, 16 | "manualControlTermination": { 17 | "type": "NEXT_TIME_BLOCK", 18 | "remainingTimeInSeconds": 4497, 19 | "projectedExpiry": "2024-12-19T21:00:00Z" 20 | }, 21 | "boostMode": null, 22 | "heatingPower": { 23 | "percentage": 0 24 | }, 25 | "connection": { 26 | "state": "CONNECTED" 27 | }, 28 | "openWindow": { 29 | "activated": true, 30 | "expiryInSeconds": 600 31 | }, 32 | "nextScheduleChange": { 33 | "start": "2024-12-19T21:00:00Z", 34 | "setting": { 35 | "power": "ON", 36 | "temperature": { 37 | "value": 18.0 38 | } 39 | } 40 | }, 41 | "nextTimeBlock": { 42 | "start": "2024-12-19T21:00:00Z" 43 | }, 44 | "balanceControl": null 45 | } 46 | -------------------------------------------------------------------------------- /tests/fixtures/tadov2.water_heater.off_mode.json: -------------------------------------------------------------------------------- 1 | { 2 | "tadoMode": "HOME", 3 | "geolocationOverride": false, 4 | "geolocationOverrideDisableTime": null, 5 | "preparation": null, 6 | "setting": { 7 | "type": "HOT_WATER", 8 | "power": "OFF", 9 | "temperature": null 10 | }, 11 | "overlayType": "MANUAL", 12 | "overlay": { 13 | "type": "MANUAL", 14 | "setting": { 15 | "type": "HOT_WATER", 16 | "power": "OFF", 17 | "temperature": null 18 | }, 19 | "termination": { 20 | "type": "MANUAL", 21 | "typeSkillBasedApp": "MANUAL", 22 | "projectedExpiry": null 23 | } 24 | }, 25 | "openWindow": null, 26 | "nextScheduleChange": { 27 | "start": "2020-03-10T22:00:00Z", 28 | "setting": { 29 | "type": "HOT_WATER", 30 | "power": "OFF", 31 | "temperature": null 32 | } 33 | }, 34 | "nextTimeBlock": { 35 | "start": "2020-03-10T22:00:00.000Z" 36 | }, 37 | "link": { 38 | "state": "ONLINE" 39 | }, 40 | "activityDataPoints": {}, 41 | "sensorDataPoints": {} 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-pypi.yml: -------------------------------------------------------------------------------- 1 | name: Build and deploy to pypi 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | deploy: 9 | name: Build distribution 10 | runs-on: ubuntu-latest 11 | 12 | environment: 13 | name: pypi 14 | url: https://pypi.org/p/python-tado 15 | 16 | permissions: 17 | id-token: write 18 | 19 | steps: 20 | - uses: actions/checkout@v6 21 | - name: Set up Python 22 | uses: actions/setup-python@v6 23 | with: 24 | python-version-file: '.python-version' 25 | - name: Install pypa/build 26 | run: >- 27 | python3 -m 28 | pip install 29 | build 30 | --user 31 | - name: Build a binary wheel and a source tarball 32 | run: python3 -m build 33 | - name: Store the distribution packages 34 | uses: actions/upload-artifact@v5 35 | with: 36 | name: python-package-distributions 37 | path: dist/ 38 | - name: Publish package 39 | uses: pypa/gh-action-pypi-publish@release/v1 40 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | # Specify the source code directories to measure coverage for. 3 | # For PyTado, this is likely the main package directory. 4 | source = PyTado 5 | 6 | # If you have scripts or command-line utilities outside the main package, 7 | # you can also include them, e.g.: source = PyTado, scripts 8 | 9 | # Omit certain files or directories from coverage. 10 | omit = 11 | tests/* 12 | # Include any other files or directories you want to exclude here. 13 | 14 | [report] 15 | # Show missing lines of coverage in the report. 16 | show_missing = True 17 | 18 | # Exclude certain lines from coverage calculations. For instance, you might exclude: 19 | #exclude_lines = 20 | exclude_also = 21 | pragma: no cover 22 | def __repr__ 23 | if self.debug: 24 | if settings.DEBUG 25 | raise AssertionError 26 | raise NotImplementedError 27 | if 0: 28 | # Defensive code 29 | if __name__ == .__main__.: 30 | raise NotImplementedError 31 | if TYPE_CHECKING: 32 | class .*\bProtocol\): 33 | @(abc\.)?abstractmethod 34 | @overload 35 | 36 | [html] 37 | # Directory where the HTML coverage report will be generated. 38 | directory = coverage_html_report 39 | -------------------------------------------------------------------------------- /tests/fixtures/smartac3.smart_mode.json: -------------------------------------------------------------------------------- 1 | { 2 | "tadoMode": "HOME", 3 | "sensorDataPoints": { 4 | "insideTemperature": { 5 | "fahrenheit": 75.97, 6 | "timestamp": "2020-03-05T03:50:24.769Z", 7 | "celsius": 24.43, 8 | "type": "TEMPERATURE", 9 | "precision": { 10 | "fahrenheit": 0.1, 11 | "celsius": 0.1 12 | } 13 | }, 14 | "humidity": { 15 | "timestamp": "2020-03-05T03:50:24.769Z", 16 | "percentage": 60.0, 17 | "type": "PERCENTAGE" 18 | } 19 | }, 20 | "link": { 21 | "state": "ONLINE" 22 | }, 23 | "openWindow": null, 24 | "geolocationOverride": false, 25 | "geolocationOverrideDisableTime": null, 26 | "overlay": null, 27 | "activityDataPoints": { 28 | "acPower": { 29 | "timestamp": "2020-03-05T03:52:22.253Z", 30 | "type": "POWER", 31 | "value": "OFF" 32 | } 33 | }, 34 | "nextTimeBlock": { 35 | "start": "2020-03-05T08:00:00.000Z" 36 | }, 37 | "preparation": null, 38 | "overlayType": null, 39 | "nextScheduleChange": null, 40 | "setting": { 41 | "fanSpeed": "MIDDLE", 42 | "type": "AIR_CONDITIONING", 43 | "mode": "COOL", 44 | "power": "ON", 45 | "temperature": { 46 | "fahrenheit": 68.0, 47 | "celsius": 20.0 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/fixtures/tadov2.water_heater.manual_mode.json: -------------------------------------------------------------------------------- 1 | { 2 | "tadoMode": "HOME", 3 | "geolocationOverride": false, 4 | "geolocationOverrideDisableTime": null, 5 | "preparation": null, 6 | "setting": { 7 | "type": "HOT_WATER", 8 | "power": "ON", 9 | "temperature": { 10 | "celsius": 55.00, 11 | "fahrenheit": 131.00 12 | } 13 | }, 14 | "overlayType": "MANUAL", 15 | "overlay": { 16 | "type": "MANUAL", 17 | "setting": { 18 | "type": "HOT_WATER", 19 | "power": "ON", 20 | "temperature": { 21 | "celsius": 55.00, 22 | "fahrenheit": 131.00 23 | } 24 | }, 25 | "termination": { 26 | "type": "MANUAL", 27 | "typeSkillBasedApp": "MANUAL", 28 | "projectedExpiry": null 29 | } 30 | }, 31 | "openWindow": null, 32 | "nextScheduleChange": { 33 | "start": "2020-03-10T22:00:00Z", 34 | "setting": { 35 | "type": "HOT_WATER", 36 | "power": "OFF", 37 | "temperature": null 38 | } 39 | }, 40 | "nextTimeBlock": { 41 | "start": "2020-03-10T22:00:00.000Z" 42 | }, 43 | "link": { 44 | "state": "ONLINE" 45 | }, 46 | "activityDataPoints": {}, 47 | "sensorDataPoints": {} 48 | } 49 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | Provide a clear and concise description of the changes you are proposing. 3 | 4 | **Example:** 5 | "This pull request fixes a bug where the library crashes when an invalid token is provided. It also updates the README with clearer setup instructions." 6 | 7 | --- 8 | 9 | ## Related Issues 10 | Link any related issues that this pull request resolves or is associated with: 11 | 12 | **Example:** 13 | - Closes #123 14 | - Related to #456 15 | 16 | --- 17 | 18 | ## Type of Changes 19 | Mark the type of changes included in this pull request: 20 | 21 | - [ ] Bugfix 22 | - [ ] New Feature 23 | - [ ] Documentation Update 24 | - [ ] Refactor 25 | - [ ] Other (please specify): 26 | 27 | --- 28 | 29 | ## Checklist 30 | - [ ] I have tested the changes locally and they work as expected. 31 | - [ ] I have added/updated necessary documentation (if applicable). 32 | - [ ] I have followed the code style and guidelines of the project. 33 | - [ ] I have searched for and linked any related issues. 34 | 35 | --- 36 | 37 | ## Additional Notes 38 | Add any additional comments, screenshots, or context for the reviewer(s). 39 | 40 | --- 41 | 42 | Thank you for your contribution to PyTado! 🎉 43 | -------------------------------------------------------------------------------- /PyTado/models/pre_line_x/device.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from PyTado.models.util import Base 4 | from PyTado.types import BatteryState 5 | 6 | 7 | class ConnectionState(Base): 8 | """ConnectionState model represents the connection state of a device.""" 9 | 10 | value: bool = False 11 | timestamp: datetime | None = None 12 | 13 | 14 | class Characteristics(Base): 15 | """Characteristics model represents the capabilities of a device.""" 16 | 17 | capabilities: list[str] 18 | 19 | 20 | class MountingState(Base): 21 | """MountingState model represents the mounting state of a device.""" 22 | 23 | value: str 24 | timestamp: datetime 25 | 26 | 27 | class Device(Base): 28 | """Device model represents a device in a zone or room.""" 29 | 30 | device_type: str 31 | serial_no: str 32 | short_serial_no: str 33 | current_fw_version: str 34 | connection_state: ConnectionState 35 | characteristics: Characteristics | None = None 36 | in_pairing_mode: bool | None = None 37 | mounting_state: MountingState | None = None 38 | mounting_state_with_error: str | None = None 39 | battery_state: BatteryState | None = None 40 | child_lock_enabled: bool | None = None 41 | orientation: str | None = None 42 | duties: list[str] | None = None 43 | -------------------------------------------------------------------------------- /public/css/prism-diff.css: -------------------------------------------------------------------------------- 1 | /* 2 | * New diff- syntax 3 | */ 4 | 5 | pre[class*="language-diff-"] { 6 | --eleventy-code-padding: 1.25em; 7 | padding-left: var(--eleventy-code-padding); 8 | padding-right: var(--eleventy-code-padding); 9 | } 10 | .token.deleted { 11 | background-color: hsl(0, 51%, 37%); 12 | color: inherit; 13 | } 14 | .token.inserted { 15 | background-color: hsl(126, 31%, 39%); 16 | color: inherit; 17 | } 18 | 19 | /* Make the + and - characters unselectable for copy/paste */ 20 | .token.prefix.unchanged, 21 | .token.prefix.inserted, 22 | .token.prefix.deleted { 23 | -webkit-user-select: none; 24 | user-select: none; 25 | display: inline-flex; 26 | align-items: center; 27 | justify-content: center; 28 | padding-top: 2px; 29 | padding-bottom: 2px; 30 | } 31 | .token.prefix.inserted, 32 | .token.prefix.deleted { 33 | width: var(--eleventy-code-padding); 34 | background-color: rgba(0,0,0,.2); 35 | } 36 | 37 | /* Optional: full-width background color */ 38 | .token.inserted:not(.prefix), 39 | .token.deleted:not(.prefix) { 40 | display: block; 41 | margin-left: calc(-1 * var(--eleventy-code-padding)); 42 | margin-right: calc(-1 * var(--eleventy-code-padding)); 43 | text-decoration: none; /* override del, ins, mark defaults */ 44 | color: inherit; /* override del, ins, mark defaults */ 45 | } 46 | -------------------------------------------------------------------------------- /tests/fixtures/smartac3.hvac_off.json: -------------------------------------------------------------------------------- 1 | { 2 | "tadoMode": "AWAY", 3 | "sensorDataPoints": { 4 | "insideTemperature": { 5 | "fahrenheit": 70.59, 6 | "timestamp": "2020-03-05T01:21:44.089Z", 7 | "celsius": 21.44, 8 | "type": "TEMPERATURE", 9 | "precision": { 10 | "fahrenheit": 0.1, 11 | "celsius": 0.1 12 | } 13 | }, 14 | "humidity": { 15 | "timestamp": "2020-03-05T01:21:44.089Z", 16 | "percentage": 48.2, 17 | "type": "PERCENTAGE" 18 | } 19 | }, 20 | "link": { 21 | "state": "ONLINE" 22 | }, 23 | "openWindow": null, 24 | "geolocationOverride": false, 25 | "geolocationOverrideDisableTime": null, 26 | "overlay": { 27 | "termination": { 28 | "typeSkillBasedApp": "MANUAL", 29 | "projectedExpiry": null, 30 | "type": "MANUAL" 31 | }, 32 | "setting": { 33 | "type": "AIR_CONDITIONING", 34 | "power": "OFF" 35 | }, 36 | "type": "MANUAL" 37 | }, 38 | "activityDataPoints": { 39 | "acPower": { 40 | "timestamp": "2020-02-29T05:34:10.318Z", 41 | "type": "POWER", 42 | "value": "OFF" 43 | } 44 | }, 45 | "nextTimeBlock": { 46 | "start": "2020-03-05T04:00:00.000Z" 47 | }, 48 | "preparation": null, 49 | "overlayType": "MANUAL", 50 | "nextScheduleChange": null, 51 | "setting": { 52 | "type": "AIR_CONDITIONING", 53 | "power": "OFF" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/fixtures/smartac3.manual_off.json: -------------------------------------------------------------------------------- 1 | { 2 | "tadoMode": "HOME", 3 | "sensorDataPoints": { 4 | "insideTemperature": { 5 | "fahrenheit": 77.02, 6 | "timestamp": "2020-03-05T04:02:07.396Z", 7 | "celsius": 25.01, 8 | "type": "TEMPERATURE", 9 | "precision": { 10 | "fahrenheit": 0.1, 11 | "celsius": 0.1 12 | } 13 | }, 14 | "humidity": { 15 | "timestamp": "2020-03-05T04:02:07.396Z", 16 | "percentage": 62.0, 17 | "type": "PERCENTAGE" 18 | } 19 | }, 20 | "link": { 21 | "state": "ONLINE" 22 | }, 23 | "openWindow": null, 24 | "geolocationOverride": false, 25 | "geolocationOverrideDisableTime": null, 26 | "overlay": { 27 | "termination": { 28 | "typeSkillBasedApp": "MANUAL", 29 | "projectedExpiry": null, 30 | "type": "MANUAL" 31 | }, 32 | "setting": { 33 | "type": "AIR_CONDITIONING", 34 | "power": "OFF" 35 | }, 36 | "type": "MANUAL" 37 | }, 38 | "activityDataPoints": { 39 | "acPower": { 40 | "timestamp": "2020-03-05T04:05:08.804Z", 41 | "type": "POWER", 42 | "value": "OFF" 43 | } 44 | }, 45 | "nextTimeBlock": { 46 | "start": "2020-03-05T08:00:00.000Z" 47 | }, 48 | "preparation": null, 49 | "overlayType": "MANUAL", 50 | "nextScheduleChange": null, 51 | "setting": { 52 | "type": "AIR_CONDITIONING", 53 | "power": "OFF" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/fixtures/smartac3.turning_off.json: -------------------------------------------------------------------------------- 1 | { 2 | "tadoMode": "HOME", 3 | "geolocationOverride": false, 4 | "geolocationOverrideDisableTime": null, 5 | "preparation": null, 6 | "setting": { 7 | "type": "AIR_CONDITIONING", 8 | "power": "OFF" 9 | }, 10 | "overlayType": "MANUAL", 11 | "overlay": { 12 | "type": "MANUAL", 13 | "setting": { 14 | "type": "AIR_CONDITIONING", 15 | "power": "OFF" 16 | }, 17 | "termination": { 18 | "type": "MANUAL", 19 | "typeSkillBasedApp": "MANUAL", 20 | "projectedExpiry": null 21 | } 22 | }, 23 | "openWindow": null, 24 | "nextScheduleChange": null, 25 | "nextTimeBlock": { 26 | "start": "2020-03-07T04:00:00.000Z" 27 | }, 28 | "link": { 29 | "state": "ONLINE" 30 | }, 31 | "activityDataPoints": { 32 | "acPower": { 33 | "timestamp": "2020-03-06T19:05:21.835Z", 34 | "type": "POWER", 35 | "value": "ON" 36 | } 37 | }, 38 | "sensorDataPoints": { 39 | "insideTemperature": { 40 | "celsius": 21.40, 41 | "fahrenheit": 70.52, 42 | "timestamp": "2020-03-06T19:06:13.185Z", 43 | "type": "TEMPERATURE", 44 | "precision": { 45 | "celsius": 0.1, 46 | "fahrenheit": 0.1 47 | } 48 | }, 49 | "humidity": { 50 | "type": "PERCENTAGE", 51 | "percentage": 49.20, 52 | "timestamp": "2020-03-06T19:06:13.185Z" 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/fixtures/smartac3.dry_mode.json: -------------------------------------------------------------------------------- 1 | { 2 | "tadoMode": "HOME", 3 | "sensorDataPoints": { 4 | "insideTemperature": { 5 | "fahrenheit": 77.02, 6 | "timestamp": "2020-03-05T04:02:07.396Z", 7 | "celsius": 25.01, 8 | "type": "TEMPERATURE", 9 | "precision": { 10 | "fahrenheit": 0.1, 11 | "celsius": 0.1 12 | } 13 | }, 14 | "humidity": { 15 | "timestamp": "2020-03-05T04:02:07.396Z", 16 | "percentage": 62.0, 17 | "type": "PERCENTAGE" 18 | } 19 | }, 20 | "link": { 21 | "state": "ONLINE" 22 | }, 23 | "openWindow": null, 24 | "geolocationOverride": false, 25 | "geolocationOverrideDisableTime": null, 26 | "overlay": { 27 | "termination": { 28 | "typeSkillBasedApp": "TADO_MODE", 29 | "projectedExpiry": null, 30 | "type": "TADO_MODE" 31 | }, 32 | "setting": { 33 | "type": "AIR_CONDITIONING", 34 | "mode": "DRY", 35 | "power": "ON" 36 | }, 37 | "type": "MANUAL" 38 | }, 39 | "activityDataPoints": { 40 | "acPower": { 41 | "timestamp": "2020-03-05T04:02:40.867Z", 42 | "type": "POWER", 43 | "value": "ON" 44 | } 45 | }, 46 | "nextTimeBlock": { 47 | "start": "2020-03-05T08:00:00.000Z" 48 | }, 49 | "preparation": null, 50 | "overlayType": "MANUAL", 51 | "nextScheduleChange": null, 52 | "setting": { 53 | "type": "AIR_CONDITIONING", 54 | "mode": "DRY", 55 | "power": "ON" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tests/fixtures/smartac3.auto_mode.json: -------------------------------------------------------------------------------- 1 | { 2 | "tadoMode": "HOME", 3 | "sensorDataPoints": { 4 | "insideTemperature": { 5 | "fahrenheit": 76.64, 6 | "timestamp": "2020-03-05T03:55:38.160Z", 7 | "celsius": 24.8, 8 | "type": "TEMPERATURE", 9 | "precision": { 10 | "fahrenheit": 0.1, 11 | "celsius": 0.1 12 | } 13 | }, 14 | "humidity": { 15 | "timestamp": "2020-03-05T03:55:38.160Z", 16 | "percentage": 62.5, 17 | "type": "PERCENTAGE" 18 | } 19 | }, 20 | "link": { 21 | "state": "ONLINE" 22 | }, 23 | "openWindow": null, 24 | "geolocationOverride": false, 25 | "geolocationOverrideDisableTime": null, 26 | "overlay": { 27 | "termination": { 28 | "typeSkillBasedApp": "TADO_MODE", 29 | "projectedExpiry": null, 30 | "type": "TADO_MODE" 31 | }, 32 | "setting": { 33 | "type": "AIR_CONDITIONING", 34 | "mode": "AUTO", 35 | "power": "ON" 36 | }, 37 | "type": "MANUAL" 38 | }, 39 | "activityDataPoints": { 40 | "acPower": { 41 | "timestamp": "2020-03-05T03:56:38.627Z", 42 | "type": "POWER", 43 | "value": "ON" 44 | } 45 | }, 46 | "nextTimeBlock": { 47 | "start": "2020-03-05T08:00:00.000Z" 48 | }, 49 | "preparation": null, 50 | "overlayType": "MANUAL", 51 | "nextScheduleChange": null, 52 | "setting": { 53 | "type": "AIR_CONDITIONING", 54 | "mode": "AUTO", 55 | "power": "ON" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tests/fixtures/ac_issue_32294.heat_mode.json: -------------------------------------------------------------------------------- 1 | { 2 | "tadoMode": "HOME", 3 | "sensorDataPoints": { 4 | "insideTemperature": { 5 | "fahrenheit": 71.28, 6 | "timestamp": "2020-02-29T22:51:05.016Z", 7 | "celsius": 21.82, 8 | "type": "TEMPERATURE", 9 | "precision": { 10 | "fahrenheit": 0.1, 11 | "celsius": 0.1 12 | } 13 | }, 14 | "humidity": { 15 | "timestamp": "2020-02-29T22:51:05.016Z", 16 | "percentage": 40.4, 17 | "type": "PERCENTAGE" 18 | } 19 | }, 20 | "link": { 21 | "state": "ONLINE" 22 | }, 23 | "openWindow": null, 24 | "geolocationOverride": false, 25 | "geolocationOverrideDisableTime": null, 26 | "overlay": null, 27 | "activityDataPoints": { 28 | "acPower": { 29 | "timestamp": "2020-02-29T22:50:34.850Z", 30 | "type": "POWER", 31 | "value": "ON" 32 | } 33 | }, 34 | "nextTimeBlock": { 35 | "start": "2020-03-01T00:00:00.000Z" 36 | }, 37 | "preparation": null, 38 | "overlayType": null, 39 | "nextScheduleChange": { 40 | "start": "2020-03-01T00:00:00Z", 41 | "setting": { 42 | "type": "AIR_CONDITIONING", 43 | "mode": "HEAT", 44 | "power": "ON", 45 | "temperature": { 46 | "fahrenheit": 59.0, 47 | "celsius": 15.0 48 | } 49 | } 50 | }, 51 | "setting": { 52 | "type": "AIR_CONDITIONING", 53 | "mode": "HEAT", 54 | "power": "ON", 55 | "temperature": { 56 | "fahrenheit": 77.0, 57 | "celsius": 25.0 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /docs/_config/filters.js: -------------------------------------------------------------------------------- 1 | import { DateTime } from "luxon"; 2 | 3 | export default function(eleventyConfig) { 4 | eleventyConfig.addFilter("readableDate", (dateObj, format, zone) => { 5 | // Formatting tokens for Luxon: https://moment.github.io/luxon/#/formatting?id=table-of-tokens 6 | return DateTime.fromJSDate(dateObj, { zone: zone || "utc" }).toFormat(format || "dd LLLL yyyy"); 7 | }); 8 | 9 | eleventyConfig.addFilter("htmlDateString", (dateObj) => { 10 | // dateObj input: https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#valid-date-string 11 | return DateTime.fromJSDate(dateObj, { zone: "utc" }).toFormat('yyyy-LL-dd'); 12 | }); 13 | 14 | // Get the first `n` elements of a collection. 15 | eleventyConfig.addFilter("head", (array, n) => { 16 | if(!Array.isArray(array) || array.length === 0) { 17 | return []; 18 | } 19 | if( n < 0 ) { 20 | return array.slice(n); 21 | } 22 | 23 | return array.slice(0, n); 24 | }); 25 | 26 | // Return the smallest number argument 27 | eleventyConfig.addFilter("min", (...numbers) => { 28 | return Math.min.apply(null, numbers); 29 | }); 30 | 31 | // Return the keys used in an object 32 | eleventyConfig.addFilter("getKeys", target => { 33 | return Object.keys(target); 34 | }); 35 | 36 | eleventyConfig.addFilter("filterTagList", function filterTagList(tags) { 37 | return (tags || []).filter(tag => ["all", "posts"].indexOf(tag) === -1); 38 | }); 39 | 40 | }; 41 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - master 7 | pull_request: ~ 8 | 9 | env: 10 | FORCE_COLOR: 1 11 | 12 | jobs: 13 | get-python-version: 14 | runs-on: ubuntu-latest 15 | outputs: 16 | python-version: ${{ steps.python-version.outputs.python_version }} 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v6 20 | 21 | - name: Read .python-version 22 | id: python-version 23 | run: | 24 | python_version=$(cat .python-version) 25 | echo "python_version=$python_version" >> "$GITHUB_OUTPUT" 26 | echo "Python version found: $python_version" 27 | 28 | lint: 29 | runs-on: ubuntu-latest 30 | needs: get-python-version 31 | steps: 32 | - uses: actions/checkout@v6 33 | 34 | - name: Set up Python 35 | uses: actions/setup-python@v6 36 | with: 37 | python-version-file: '.python-version' 38 | 39 | - name: Cache dependencies 40 | uses: actions/cache@v4 41 | with: 42 | path: ${{ env.pythonLocation }} 43 | key: ${{ runner.os }}-${{ needs.get-python-version.outputs.python-version }}-pip-${{ hashFiles('pyproject.toml') }} 44 | 45 | - name: Install dependencies 46 | run: | 47 | python -m pip install --upgrade pip 48 | pip install -e '.[all]' 49 | 50 | - name: Run pre-commit hooks 51 | uses: pre-commit/action@v3.0.1 52 | -------------------------------------------------------------------------------- /tests/fixtures/tadov2.heating.auto_mode.json: -------------------------------------------------------------------------------- 1 | { 2 | "tadoMode": "HOME", 3 | "geolocationOverride": false, 4 | "geolocationOverrideDisableTime": null, 5 | "preparation": null, 6 | "setting": { 7 | "type": "HEATING", 8 | "power": "ON", 9 | "temperature": { 10 | "celsius": 20.00, 11 | "fahrenheit": 68.00 12 | } 13 | }, 14 | "overlayType": null, 15 | "overlay": null, 16 | "openWindow": null, 17 | "nextScheduleChange": { 18 | "start": "2020-03-10T17:00:00Z", 19 | "setting": { 20 | "type": "HEATING", 21 | "power": "ON", 22 | "temperature": { 23 | "celsius": 21.00, 24 | "fahrenheit": 69.80 25 | } 26 | } 27 | }, 28 | "nextTimeBlock": { 29 | "start": "2020-03-10T17:00:00.000Z" 30 | }, 31 | "link": { 32 | "state": "ONLINE" 33 | }, 34 | "activityDataPoints": { 35 | "heatingPower": { 36 | "type": "PERCENTAGE", 37 | "percentage": 0.00, 38 | "timestamp": "2020-03-10T07:47:45.978Z" 39 | } 40 | }, 41 | "sensorDataPoints": { 42 | "insideTemperature": { 43 | "celsius": 20.65, 44 | "fahrenheit": 69.17, 45 | "timestamp": "2020-03-10T07:44:11.947Z", 46 | "type": "TEMPERATURE", 47 | "precision": { 48 | "celsius": 0.1, 49 | "fahrenheit": 0.1 50 | } 51 | }, 52 | "humidity": { 53 | "type": "PERCENTAGE", 54 | "percentage": 45.20, 55 | "timestamp": "2020-03-10T07:44:11.947Z" 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest a new feature or improvement for PyTado. 4 | title: '[Feature]: ' 5 | labels: 'enhancement' 6 | assignees: '' 7 | --- 8 | 9 | ## Describe the Feature 10 | A clear and concise description of the feature or improvement you are suggesting. 11 | 12 | **Example:** 13 | "Add support for scheduling temperature changes based on weather conditions." 14 | 15 | --- 16 | 17 | ## Why Is This Feature Useful? 18 | Explain why this feature would be beneficial for PyTado users or how it improves the project. 19 | 20 | --- 21 | 22 | ## Proposed Solution 23 | If you have a suggestion for how to implement the feature, provide details here. 24 | 25 | **Example:** 26 | "Use the OpenWeatherMap API to fetch weather conditions and add an option in the settings to enable weather-based temperature adjustments." 27 | 28 | --- 29 | 30 | ## Alternatives Considered 31 | List any alternative approaches or features you considered and why they might not work as well. 32 | 33 | --- 34 | 35 | ## Additional Context 36 | Add any other context, screenshots, or examples that help explain your request. 37 | 38 | --- 39 | 40 | ### Checklist 41 | - [ ] I have searched the existing issues and discussions to ensure this is not a duplicate. 42 | - [ ] I have provided a clear and concise description of my feature request. 43 | - [ ] I have included any relevant examples or details to support my proposal. 44 | 45 | Thank you for helping to improve PyTado! 🚀 46 | -------------------------------------------------------------------------------- /tests/fixtures/smartac3.fan_mode.json: -------------------------------------------------------------------------------- 1 | { 2 | "tadoMode": "HOME", 3 | "sensorDataPoints": { 4 | "insideTemperature": { 5 | "fahrenheit": 77.02, 6 | "timestamp": "2020-03-05T04:02:07.396Z", 7 | "celsius": 25.01, 8 | "type": "TEMPERATURE", 9 | "precision": { 10 | "fahrenheit": 0.1, 11 | "celsius": 0.1 12 | } 13 | }, 14 | "humidity": { 15 | "timestamp": "2020-03-05T04:02:07.396Z", 16 | "percentage": 62.0, 17 | "type": "PERCENTAGE" 18 | } 19 | }, 20 | "link": { 21 | "state": "ONLINE" 22 | }, 23 | "openWindow": null, 24 | "geolocationOverride": false, 25 | "geolocationOverrideDisableTime": null, 26 | "overlay": { 27 | "termination": { 28 | "typeSkillBasedApp": "TADO_MODE", 29 | "projectedExpiry": null, 30 | "type": "TADO_MODE" 31 | }, 32 | "setting": { 33 | "type": "AIR_CONDITIONING", 34 | "mode": "FAN", 35 | "power": "ON" 36 | }, 37 | "type": "MANUAL" 38 | }, 39 | "activityDataPoints": { 40 | "acPower": { 41 | "timestamp": "2020-03-05T04:03:44.328Z", 42 | "type": "POWER", 43 | "value": "ON" 44 | } 45 | }, 46 | "nextTimeBlock": { 47 | "start": "2020-03-05T08:00:00.000Z" 48 | }, 49 | "preparation": null, 50 | "overlayType": "MANUAL", 51 | "nextScheduleChange": null, 52 | "setting": { 53 | "type": "AIR_CONDITIONING", 54 | "mode": "FAN", 55 | "power": "ON", 56 | "fanLevel": "AUTO" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/fixtures/tadox/hops_tado_homes_heatPump.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "HEAT_PUMP", 3 | "connection": { "state": "CONNECTED" }, 4 | "heating": { 5 | "available": true, 6 | "capabilities": { 7 | "temperatureRangeInCelsius": { "min": 10.0, "max": 30.0, "step": 1.0 } 8 | }, 9 | "currentBlockSetpoint": { 10 | "setpointType": "CUSTOM", 11 | "setpointValue": { "valueType": "TEMPERATURE_C", "value": "21.0" } 12 | }, 13 | "nextBlockSetpoint": { 14 | "setpointType": "CUSTOM", 15 | "setpointValue": { "valueType": "TEMPERATURE_C", "value": "20.0" } 16 | }, 17 | "nextBlockStartTime": "2024-10-17T18:00:00Z", 18 | "roomGuidedModeActive": true, 19 | "standbyActive": true, 20 | "overlayActive": false, 21 | "overlaySetpoint": null, 22 | "setting": { "power": "OFF", "temperature": null }, 23 | "manualControlTermination": null, 24 | "loadShiftingActive": false, 25 | "loadShiftingTermination": null, 26 | "heatingActivityInPercent": 0, 27 | "showRoomGuidedModeNotification": false 28 | }, 29 | "domesticHotWater": { 30 | "available": true, 31 | "currentBlockSetpoint": { 32 | "setpointType": "FALLBACK", 33 | "setpointValue": { "valueType": "TEMPERATURE_C", "value": "40.0" } 34 | }, 35 | "nextBlockSetpoint": null, 36 | "nextBlockStartTime": null, 37 | "currentTemperatureInCelsius": 62.0, 38 | "heatingActivityInPercent": 0, 39 | "manualOffActive": false, 40 | "tankIsFullyLoaded": true, 41 | "boostActive": false 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/fixtures/home_1234/tadov2.my_api_v2_home_state.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 1234, 3 | "name": "My Home - Tado v1-v3+", 4 | "dateTimeZone": "Europe/Berlin", 5 | "dateCreated": "2024-12-04T21:53:35.862Z", 6 | "temperatureUnit": "CELSIUS", 7 | "partner": null, 8 | "simpleSmartScheduleEnabled": true, 9 | "awayRadiusInMeters": 400.00, 10 | "installationCompleted": true, 11 | "incidentDetection": {"supported": false, "enabled": true}, 12 | "zonesCount": 0, 13 | "language": "de-DE", 14 | "preventFromSubscribing": true, 15 | "skills": [], 16 | "christmasModeEnabled": true, 17 | "showAutoAssistReminders": true, 18 | "contactDetails": { 19 | "name": "Alice Wonderland", 20 | "email": "alice-in@wonder.land", 21 | "phone": "+00000000" 22 | }, 23 | "address": { 24 | "addressLine1": "Wonderland 1", 25 | "addressLine2": null, 26 | "zipCode": "112", 27 | "city": "Wonderland", 28 | "state": null, 29 | "country": "DEU" 30 | }, 31 | "geolocation": {"latitude": 25.1532934, "longitude": 2.3324432}, 32 | "consentGrantSkippable": true, 33 | "enabledFeatures": [ 34 | "AA_REVERSE_TRIAL_7D", 35 | "EIQ_SETTINGS_AS_WEBVIEW", 36 | "HIDE_BOILER_REPAIR_SERVICE", 37 | "OWD_SETTINGS_AS_WEBVIEW", 38 | "SETTINGS_OVERVIEW_AS_WEBVIEW" 39 | ], 40 | "isAirComfortEligible": false, 41 | "isBalanceAcEligible": false, 42 | "isEnergyIqEligible": true, 43 | "isHeatSourceInstalled": false, 44 | "isHeatPumpInstalled": false 45 | } 46 | -------------------------------------------------------------------------------- /tests/fixtures/smartac3.cool_mode.json: -------------------------------------------------------------------------------- 1 | { 2 | "tadoMode": "HOME", 3 | "sensorDataPoints": { 4 | "insideTemperature": { 5 | "fahrenheit": 76.57, 6 | "timestamp": "2020-03-05T03:57:38.850Z", 7 | "celsius": 24.76, 8 | "type": "TEMPERATURE", 9 | "precision": { 10 | "fahrenheit": 0.1, 11 | "celsius": 0.1 12 | } 13 | }, 14 | "humidity": { 15 | "timestamp": "2020-03-05T03:57:38.850Z", 16 | "percentage": 60.9, 17 | "type": "PERCENTAGE" 18 | } 19 | }, 20 | "link": { 21 | "state": "ONLINE" 22 | }, 23 | "openWindow": null, 24 | "geolocationOverride": false, 25 | "geolocationOverrideDisableTime": null, 26 | "overlay": { 27 | "termination": { 28 | "typeSkillBasedApp": "TADO_MODE", 29 | "projectedExpiry": null, 30 | "type": "TADO_MODE" 31 | }, 32 | "setting": { 33 | "fanSpeed": "AUTO", 34 | "type": "AIR_CONDITIONING", 35 | "mode": "COOL", 36 | "power": "ON", 37 | "temperature": { 38 | "fahrenheit": 64.0, 39 | "celsius": 17.78 40 | } 41 | }, 42 | "type": "MANUAL" 43 | }, 44 | "activityDataPoints": { 45 | "acPower": { 46 | "timestamp": "2020-03-05T04:01:07.162Z", 47 | "type": "POWER", 48 | "value": "ON" 49 | } 50 | }, 51 | "nextTimeBlock": { 52 | "start": "2020-03-05T08:00:00.000Z" 53 | }, 54 | "preparation": null, 55 | "overlayType": "MANUAL", 56 | "nextScheduleChange": null, 57 | "setting": { 58 | "fanSpeed": "AUTO", 59 | "type": "AIR_CONDITIONING", 60 | "mode": "COOL", 61 | "power": "ON", 62 | "temperature": { 63 | "fahrenheit": 64.0, 64 | "celsius": 17.78 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tests/fixtures/smartac3.heat_mode.json: -------------------------------------------------------------------------------- 1 | { 2 | "tadoMode": "HOME", 3 | "sensorDataPoints": { 4 | "insideTemperature": { 5 | "fahrenheit": 76.57, 6 | "timestamp": "2020-03-05T03:57:38.850Z", 7 | "celsius": 24.76, 8 | "type": "TEMPERATURE", 9 | "precision": { 10 | "fahrenheit": 0.1, 11 | "celsius": 0.1 12 | } 13 | }, 14 | "humidity": { 15 | "timestamp": "2020-03-05T03:57:38.850Z", 16 | "percentage": 60.9, 17 | "type": "PERCENTAGE" 18 | } 19 | }, 20 | "link": { 21 | "state": "ONLINE" 22 | }, 23 | "openWindow": null, 24 | "geolocationOverride": false, 25 | "geolocationOverrideDisableTime": null, 26 | "overlay": { 27 | "termination": { 28 | "typeSkillBasedApp": "TADO_MODE", 29 | "projectedExpiry": null, 30 | "type": "TADO_MODE" 31 | }, 32 | "setting": { 33 | "fanSpeed": "AUTO", 34 | "type": "AIR_CONDITIONING", 35 | "mode": "HEAT", 36 | "power": "ON", 37 | "temperature": { 38 | "fahrenheit": 61.0, 39 | "celsius": 16.11 40 | } 41 | }, 42 | "type": "MANUAL" 43 | }, 44 | "activityDataPoints": { 45 | "acPower": { 46 | "timestamp": "2020-03-05T03:59:36.390Z", 47 | "type": "POWER", 48 | "value": "ON" 49 | } 50 | }, 51 | "nextTimeBlock": { 52 | "start": "2020-03-05T08:00:00.000Z" 53 | }, 54 | "preparation": null, 55 | "overlayType": "MANUAL", 56 | "nextScheduleChange": null, 57 | "setting": { 58 | "fanSpeed": "AUTO", 59 | "type": "AIR_CONDITIONING", 60 | "mode": "HEAT", 61 | "power": "ON", 62 | "temperature": { 63 | "fahrenheit": 61.0, 64 | "celsius": 16.11 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tests/fixtures/home_1234/tadox.my_api_v2_home_state.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 1234, 3 | "name": "My Home - TadoX", 4 | "dateTimeZone": "Europe/Berlin", 5 | "dateCreated": "2024-12-04T21:53:35.862Z", 6 | "temperatureUnit": "CELSIUS", 7 | "partner": null, 8 | "simpleSmartScheduleEnabled": true, 9 | "awayRadiusInMeters": 400.00, 10 | "installationCompleted": true, 11 | "incidentDetection": {"supported": false, "enabled": true}, 12 | "generation": "LINE_X", 13 | "zonesCount": 0, 14 | "language": "de-DE", 15 | "preventFromSubscribing": true, 16 | "skills": [], 17 | "christmasModeEnabled": true, 18 | "showAutoAssistReminders": true, 19 | "contactDetails": { 20 | "name": "Alice Wonderland", 21 | "email": "alice-in@wonder.land", 22 | "phone": "+00000000" 23 | }, 24 | "address": { 25 | "addressLine1": "Wonderland 1", 26 | "addressLine2": null, 27 | "zipCode": "112", 28 | "city": "Wonderland", 29 | "state": null, 30 | "country": "DEU" 31 | }, 32 | "geolocation": {"latitude": 25.1532934, "longitude": 2.3324432}, 33 | "consentGrantSkippable": true, 34 | "enabledFeatures": [ 35 | "AA_REVERSE_TRIAL_7D", 36 | "EIQ_SETTINGS_AS_WEBVIEW", 37 | "HIDE_BOILER_REPAIR_SERVICE", 38 | "OWD_SETTINGS_AS_WEBVIEW", 39 | "SETTINGS_OVERVIEW_AS_WEBVIEW" 40 | ], 41 | "isAirComfortEligible": false, 42 | "isBalanceAcEligible": false, 43 | "isEnergyIqEligible": true, 44 | "isHeatSourceInstalled": false, 45 | "isHeatPumpInstalled": false 46 | } 47 | -------------------------------------------------------------------------------- /docs/_includes/layouts/base.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ title or metadata.title }} 7 | 8 | 9 | 10 | {%- css %}{% include "public/css/index.css" %}{% endcss %} 11 | 12 | 13 | {%- js %}{% include "node_modules/@zachleat/heading-anchors/heading-anchors.js" %}{% endjs %} 14 | 15 | 16 | Skip to main content 17 | 18 |
19 | {{ metadata.title }} 20 | 21 | 29 |
30 | 31 |
32 | 33 | {{ content | safe }} 34 | 35 |
36 | 37 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /PyTado/models/common/schedule.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, TypeVar 2 | 3 | from pydantic import field_validator 4 | 5 | from PyTado.models.util import Base 6 | from PyTado.types import ( 7 | DayType, 8 | FanSpeed, 9 | HorizontalSwing, 10 | HvacMode, 11 | Power, 12 | VerticalSwing, 13 | ZoneType, 14 | ) 15 | 16 | T = TypeVar("T") 17 | 18 | 19 | class Setting(Base, Generic[T]): 20 | type: ZoneType | None = None 21 | power: Power 22 | temperature: T 23 | mode: HvacMode | None = None 24 | fan_level: FanSpeed | None = None 25 | vertical_swing: VerticalSwing | None = None 26 | horizontal_swing: HorizontalSwing | None = None 27 | light: Power | None = None 28 | 29 | 30 | class ScheduleElement(Base, Generic[T]): 31 | """ScheduleElement model represents one Block of a schedule. 32 | Tado v3 API days go from 00:00 to 00:00 33 | Tado X API days go from 00:00 to 24:00 34 | """ 35 | 36 | day_type: DayType 37 | start: str 38 | end: str 39 | geolocation_override: bool | None = None 40 | setting: Setting[T] 41 | 42 | @field_validator("start", "end") 43 | def validate_time(cls, value: str) -> str: 44 | try: 45 | hour, minute = value.split(":") 46 | if not 0 <= int(hour) <= 24: 47 | raise ValueError(f"Hour {hour} is not between 0 and 24") 48 | if not 0 <= int(minute) < 60: 49 | raise ValueError(f"Minute {minute} is not between 0 and 59") 50 | except Exception as e: 51 | raise ValueError(f"Invalid time format {value}") from e 52 | return value 53 | -------------------------------------------------------------------------------- /tests/fixtures/smartac3.with_swing.json: -------------------------------------------------------------------------------- 1 | { 2 | "tadoMode": "HOME", 3 | "geolocationOverride": false, 4 | "geolocationOverrideDisableTime": null, 5 | "preparation": null, 6 | "setting": { 7 | "type": "AIR_CONDITIONING", 8 | "power": "ON", 9 | "mode": "HEAT", 10 | "temperature": { 11 | "celsius": 20.0, 12 | "fahrenheit": 68.0 13 | }, 14 | "fanLevel": "AUTO", 15 | "verticalSwing": "ON", 16 | "horizontalSwing": "ON" 17 | }, 18 | "overlayType": null, 19 | "overlay": null, 20 | "openWindow": null, 21 | "nextScheduleChange": { 22 | "start": "2020-03-28T04:30:00Z", 23 | "setting": { 24 | "type": "AIR_CONDITIONING", 25 | "power": "ON", 26 | "mode": "HEAT", 27 | "temperature": { 28 | "celsius": 23.0, 29 | "fahrenheit": 73.4 30 | }, 31 | "fanSpeed": "AUTO", 32 | "swing": "ON" 33 | } 34 | }, 35 | "nextTimeBlock": { 36 | "start": "2020-03-28T04:30:00.000Z" 37 | }, 38 | "link": { 39 | "state": "ONLINE" 40 | }, 41 | "activityDataPoints": { 42 | "acPower": { 43 | "timestamp": "2020-03-27T23:02:22.260Z", 44 | "type": "POWER", 45 | "value": "ON" 46 | } 47 | }, 48 | "sensorDataPoints": { 49 | "insideTemperature": { 50 | "celsius": 20.88, 51 | "fahrenheit": 69.58, 52 | "timestamp": "2020-03-28T02:09:27.830Z", 53 | "type": "TEMPERATURE", 54 | "precision": { 55 | "celsius": 0.1, 56 | "fahrenheit": 0.1 57 | } 58 | }, 59 | "humidity": { 60 | "type": "PERCENTAGE", 61 | "percentage": 42.3, 62 | "timestamp": "2020-03-28T02:09:27.830Z" 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /tests/fixtures/smartac3.offline.json: -------------------------------------------------------------------------------- 1 | { 2 | "tadoMode": "HOME", 3 | "sensorDataPoints": { 4 | "insideTemperature": { 5 | "fahrenheit": 77.09, 6 | "timestamp": "2020-03-03T21:23:57.846Z", 7 | "celsius": 25.05, 8 | "type": "TEMPERATURE", 9 | "precision": { 10 | "fahrenheit": 0.1, 11 | "celsius": 0.1 12 | } 13 | }, 14 | "humidity": { 15 | "timestamp": "2020-03-03T21:23:57.846Z", 16 | "percentage": 61.6, 17 | "type": "PERCENTAGE" 18 | } 19 | }, 20 | "link": { 21 | "state": "OFFLINE", 22 | "reason": { 23 | "code": "disconnectedDevice", 24 | "title": "There is a disconnected device." 25 | } 26 | }, 27 | "openWindow": null, 28 | "geolocationOverride": false, 29 | "geolocationOverrideDisableTime": null, 30 | "overlay": { 31 | "termination": { 32 | "typeSkillBasedApp": "TADO_MODE", 33 | "projectedExpiry": null, 34 | "type": "TADO_MODE" 35 | }, 36 | "setting": { 37 | "fanSpeed": "AUTO", 38 | "type": "AIR_CONDITIONING", 39 | "mode": "COOL", 40 | "power": "ON", 41 | "temperature": { 42 | "fahrenheit": 64.0, 43 | "celsius": 17.78 44 | } 45 | }, 46 | "type": "MANUAL" 47 | }, 48 | "activityDataPoints": { 49 | "acPower": { 50 | "timestamp": "2020-02-29T18:42:26.683Z", 51 | "type": "POWER", 52 | "value": "OFF" 53 | } 54 | }, 55 | "nextTimeBlock": { 56 | "start": "2020-03-05T08:00:00.000Z" 57 | }, 58 | "preparation": null, 59 | "overlayType": "MANUAL", 60 | "nextScheduleChange": null, 61 | "setting": { 62 | "fanSpeed": "AUTO", 63 | "type": "AIR_CONDITIONING", 64 | "mode": "COOL", 65 | "power": "ON", 66 | "temperature": { 67 | "fahrenheit": 64.0, 68 | "celsius": 17.78 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/fixtures/tadov2.heating.off_mode.json: -------------------------------------------------------------------------------- 1 | { 2 | "tadoMode": "HOME", 3 | "geolocationOverride": false, 4 | "geolocationOverrideDisableTime": null, 5 | "preparation": null, 6 | "setting": { 7 | "type": "HEATING", 8 | "power": "OFF", 9 | "temperature": null 10 | }, 11 | "overlayType": "MANUAL", 12 | "overlay": { 13 | "type": "MANUAL", 14 | "setting": { 15 | "type": "HEATING", 16 | "power": "OFF", 17 | "temperature": null 18 | }, 19 | "termination": { 20 | "type": "MANUAL", 21 | "typeSkillBasedApp": "MANUAL", 22 | "projectedExpiry": null 23 | } 24 | }, 25 | "openWindow": null, 26 | "nextScheduleChange": { 27 | "start": "2020-03-10T17:00:00Z", 28 | "setting": { 29 | "type": "HEATING", 30 | "power": "ON", 31 | "temperature": { 32 | "celsius": 21.00, 33 | "fahrenheit": 69.80 34 | } 35 | } 36 | }, 37 | "nextTimeBlock": { 38 | "start": "2020-03-10T17:00:00.000Z" 39 | }, 40 | "link": { 41 | "state": "ONLINE" 42 | }, 43 | "activityDataPoints": { 44 | "heatingPower": { 45 | "type": "PERCENTAGE", 46 | "percentage": 0.00, 47 | "timestamp": "2020-03-10T07:47:45.978Z" 48 | } 49 | }, 50 | "sensorDataPoints": { 51 | "insideTemperature": { 52 | "celsius": 20.65, 53 | "fahrenheit": 69.17, 54 | "timestamp": "2020-03-10T07:44:11.947Z", 55 | "type": "TEMPERATURE", 56 | "precision": { 57 | "celsius": 0.1, 58 | "fahrenheit": 0.1 59 | } 60 | }, 61 | "humidity": { 62 | "type": "PERCENTAGE", 63 | "percentage": 45.20, 64 | "timestamp": "2020-03-10T07:44:11.947Z" 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report a bug to help improve PyTado. 4 | title: "[Bug]: " 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | ## Describe the Bug 10 | A clear and concise description of what the bug is. 11 | 12 | **Example:** 13 | "The PyTado integration fails to update the thermostat state when attempting to set the temperature." 14 | 15 | --- 16 | 17 | ## Steps to Reproduce 18 | Steps to reproduce the behavior: 19 | 1. Go to '...' 20 | 2. Click on '...' 21 | 3. Observe '...' 22 | 23 | **Expected behavior:** 24 | Explain what you expected to happen. 25 | 26 | **Actual behavior:** 27 | Explain what actually happened. 28 | 29 | --- 30 | 31 | ## Environment 32 | Provide details about your environment where the bug occurred: 33 | - **PyTado Version:** e.g., `v1.2.3` 34 | - **Python Version:** e.g., `3.10` 35 | - **Operating System:** e.g., `Windows 10`, `Ubuntu 22.04` 36 | - **Tado Firmware Version (if applicable):** e.g., `88.1` 37 | - **Tado Generation:** e.g., `v1`, `v2` or `line_x` 38 | 39 | --- 40 | 41 | ## Logs and Error Messages 42 | If applicable, provide logs, error messages, or screenshots: 43 | ```plaintext 44 | Paste any relevant error message or logs here. 45 | ``` 46 | 47 | --- 48 | 49 | ## Additional Context 50 | Add any other context about the problem here. If the issue is related to a specific API call or a particular Tado configuration, please specify it. 51 | 52 | --- 53 | 54 | ### Checklist 55 | - [ ] I have searched the existing issues and discussions to ensure this is not a duplicate. 56 | - [ ] I have provided all necessary information above to help reproduce the bug. 57 | - [ ] I have included relevant logs or screenshots (if applicable). 58 | 59 | Thank you for helping to improve PyTado! 😊 60 | -------------------------------------------------------------------------------- /.github/workflows/deploy-11ty-pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - gh-pages 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | 15 | steps: 16 | - uses: actions/checkout@v6 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v6 20 | 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install -e '.[all]' 25 | 26 | - name: Setup Node 27 | uses: actions/setup-node@v6 28 | with: 29 | node-version: "18.x" 30 | 31 | - name: Persist npm cache 32 | uses: actions/cache@v4 33 | with: 34 | path: ~/.npm 35 | key: ${{ runner.os }}-node-${{ hashFiles('**/package.json') }} 36 | 37 | - name: Persist Eleventy .cache 38 | uses: actions/cache@v4 39 | with: 40 | path: ./.cache 41 | key: ${{ runner.os }}-eleventy-fetch-cache 42 | 43 | - run: npm install 44 | - run: python generate_api_docs.py 45 | - run: npm run build-ghpages 46 | 47 | - name: Upload gh-pages artifacts 48 | uses: actions/upload-pages-artifact@v4 49 | with: 50 | path: ./_site/ 51 | 52 | deploy: 53 | needs: build 54 | 55 | permissions: 56 | pages: write # to deploy to Pages 57 | id-token: write # to verify the deployment originates from an appropriate source 58 | 59 | environment: 60 | name: github-pages 61 | url: ${{ steps.deployment.outputs.page_url }} 62 | 63 | runs-on: ubuntu-latest 64 | steps: 65 | - name: Deploy to GitHub Pages 66 | id: deployment 67 | uses: actions/deploy-pages@v4 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pytado", 3 | "version": "1.0.0", 4 | "description": "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", 5 | "main": "index.js", 6 | "directories": { 7 | "doc": "docs", 8 | "example": "examples", 9 | "test": "tests" 10 | }, 11 | "scripts": { 12 | "build": "npx @11ty/eleventy", 13 | "build-nocolor": "cross-env NODE_DISABLE_COLORS=1 npx @11ty/eleventy", 14 | "build-ghpages": "npx @11ty/eleventy --pathprefix=/PyTado/", 15 | "start": "npx @11ty/eleventy --serve --quiet", 16 | "start-ghpages": "npx @11ty/eleventy --pathprefix=/PyTado/ --serve --quiet", 17 | "debug": "cross-env DEBUG=Eleventy* npx @11ty/eleventy", 18 | "debugstart": "cross-env DEBUG=Eleventy* npx @11ty/eleventy --serve --quiet", 19 | "benchmark": "cross-env DEBUG=Eleventy:Benchmark* npx @11ty/eleventy" 20 | }, 21 | "keywords": [], 22 | "author": "Wolfgang Malgadey", 23 | "license": "GPL v3", 24 | "engines": { 25 | "node": ">=18" 26 | }, 27 | "type": "module", 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/wmalgadey/PyTado.git" 31 | }, 32 | "bugs": { 33 | "url": "https://github.com/wmalgadey/PyTado/issues" 34 | }, 35 | "homepage": "https://github.com/wmalgadey/PyTado", 36 | "devDependencies": { 37 | "@11ty/eleventy": "^3.0.0", 38 | "@11ty/eleventy-navigation": "^0.3.5", 39 | "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.0", 40 | "cross-env": "^7.0.3", 41 | "luxon": "^3.5.0", 42 | "zod": "^3.23.8", 43 | "zod-validation-error": "^3.3.1" 44 | }, 45 | "dependencies": { 46 | "@zachleat/heading-anchors": "^1.0.1" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/fixtures/tadov2.heating.manual_mode.json: -------------------------------------------------------------------------------- 1 | { 2 | "tadoMode": "HOME", 3 | "geolocationOverride": false, 4 | "geolocationOverrideDisableTime": null, 5 | "preparation": null, 6 | "setting": { 7 | "type": "HEATING", 8 | "power": "ON", 9 | "temperature": { 10 | "celsius": 20.50, 11 | "fahrenheit": 68.90 12 | } 13 | }, 14 | "overlayType": "MANUAL", 15 | "overlay": { 16 | "type": "MANUAL", 17 | "setting": { 18 | "type": "HEATING", 19 | "power": "ON", 20 | "temperature": { 21 | "celsius": 20.50, 22 | "fahrenheit": 68.90 23 | } 24 | }, 25 | "termination": { 26 | "type": "MANUAL", 27 | "typeSkillBasedApp": "MANUAL", 28 | "projectedExpiry": null 29 | } 30 | }, 31 | "openWindow": null, 32 | "nextScheduleChange": { 33 | "start": "2020-03-10T17:00:00Z", 34 | "setting": { 35 | "type": "HEATING", 36 | "power": "ON", 37 | "temperature": { 38 | "celsius": 21.00, 39 | "fahrenheit": 69.80 40 | } 41 | } 42 | }, 43 | "nextTimeBlock": { 44 | "start": "2020-03-10T17:00:00.000Z" 45 | }, 46 | "link": { 47 | "state": "ONLINE" 48 | }, 49 | "activityDataPoints": { 50 | "heatingPower": { 51 | "type": "PERCENTAGE", 52 | "percentage": 0.00, 53 | "timestamp": "2020-03-10T07:47:45.978Z" 54 | } 55 | }, 56 | "sensorDataPoints": { 57 | "insideTemperature": { 58 | "celsius": 20.65, 59 | "fahrenheit": 69.17, 60 | "timestamp": "2020-03-10T07:44:11.947Z", 61 | "type": "TEMPERATURE", 62 | "precision": { 63 | "celsius": 0.1, 64 | "fahrenheit": 0.1 65 | } 66 | }, 67 | "humidity": { 68 | "type": "PERCENTAGE", 69 | "percentage": 45.20, 70 | "timestamp": "2020-03-10T07:44:11.947Z" 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /PyTado/models/line_x/device.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Self 2 | 3 | from pydantic import model_validator 4 | 5 | from PyTado.models.util import Base 6 | from PyTado.types import BatteryState, ConnectionState, OverlayMode 7 | 8 | 9 | class Connection(Base): 10 | state: ConnectionState 11 | 12 | 13 | class Device(Base): 14 | """Device model represents a device in a zone or room.""" 15 | 16 | serial_number: str 17 | type: str 18 | firmware_version: str 19 | connection: Connection 20 | mounting_state: str | None = None # TODO: Use Enum or something similar 21 | battery_state: BatteryState | None = None 22 | child_lock_enabled: bool | None = None 23 | temperature_as_measured: float | None = None 24 | temperature_offset: float | None = None 25 | room_id: int | None = None 26 | room_name: str | None = None 27 | 28 | 29 | class DeviceManualControlTermination(Base): 30 | type: OverlayMode 31 | durationInSeconds: int | None = None 32 | 33 | 34 | class DevicesRooms(Base): 35 | room_id: int 36 | room_name: str 37 | device_manual_control_termination: DeviceManualControlTermination 38 | devices: list[Device] 39 | zone_controller_assignable: bool | None = None 40 | zone_controllers: list[Any] | None = None 41 | 42 | @model_validator(mode="after") 43 | def set_device_room(self) -> Self: 44 | for device in self.devices: 45 | device.room_id = self.room_id 46 | device.room_name = self.room_name 47 | return self 48 | 49 | 50 | class DevicesResponse(Base): 51 | """DevicesResponse model represents the response of the devices endpoint.""" 52 | 53 | rooms: list[DevicesRooms] 54 | other_devices: list[Device] 55 | 56 | 57 | class ActionableDevice: 58 | serial_number: str 59 | needs_mounting: bool 60 | isOffline: bool 61 | batteryState: BatteryState 62 | -------------------------------------------------------------------------------- /tests/fixtures/tadox/rooms_and_devices.json: -------------------------------------------------------------------------------- 1 | { 2 | "otherDevices": [ 3 | { 4 | "connection": { 5 | "state": "CONNECTED" 6 | }, 7 | "firmwareVersion": "245.1", 8 | "serialNumber": "IB1234567890", 9 | "type": "IB02" 10 | } 11 | ], 12 | "rooms": [ 13 | { 14 | "deviceManualControlTermination": { 15 | "durationInSeconds": null, 16 | "type": "MANUAL" 17 | }, 18 | "devices": [ 19 | { 20 | "batteryState": "NORMAL", 21 | "childLockEnabled": false, 22 | "connection": { 23 | "state": "CONNECTED" 24 | }, 25 | "firmwareVersion": "243.1", 26 | "mountingState": "CALIBRATED", 27 | "serialNumber": "VA1234567890", 28 | "temperatureAsMeasured": 17.00, 29 | "temperatureOffset": 0.0, 30 | "type": "VA04" 31 | } 32 | ], 33 | "roomId": 1, 34 | "roomName": "Room 1", 35 | "zoneControllerAssignable": false, 36 | "zoneControllers": [] 37 | }, 38 | { 39 | "deviceManualControlTermination": { 40 | "durationInSeconds": null, 41 | "type": "MANUAL" 42 | }, 43 | "devices": [ 44 | { 45 | "batteryState": "NORMAL", 46 | "childLockEnabled": false, 47 | "connection": { 48 | "state": "CONNECTED" 49 | }, 50 | "firmwareVersion": "243.1", 51 | "mountingState": "CALIBRATED", 52 | "serialNumber": "VA1234567891", 53 | "temperatureAsMeasured": 18.00, 54 | "temperatureOffset": 0.0, 55 | "type": "VA04" 56 | } 57 | ], 58 | "roomId": 2, 59 | "roomName": " Room 2", 60 | "zoneControllerAssignable": false, 61 | "zoneControllers": [] 62 | } 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /tests/fixtures/hvac_action_heat.json: -------------------------------------------------------------------------------- 1 | { 2 | "tadoMode": "HOME", 3 | "geolocationOverride": false, 4 | "geolocationOverrideDisableTime": null, 5 | "preparation": null, 6 | "setting": { 7 | "type": "AIR_CONDITIONING", 8 | "power": "ON", 9 | "mode": "HEAT", 10 | "temperature": { 11 | "celsius": 16.11, 12 | "fahrenheit": 61.00 13 | }, 14 | "fanSpeed": "AUTO" 15 | }, 16 | "overlayType": "MANUAL", 17 | "overlay": { 18 | "type": "MANUAL", 19 | "setting": { 20 | "type": "AIR_CONDITIONING", 21 | "power": "ON", 22 | "mode": "HEAT", 23 | "temperature": { 24 | "celsius": 16.11, 25 | "fahrenheit": 61.00 26 | }, 27 | "fanSpeed": "AUTO" 28 | }, 29 | "termination": { 30 | "type": "TADO_MODE", 31 | "typeSkillBasedApp": "TADO_MODE", 32 | "projectedExpiry": null 33 | } 34 | }, 35 | "openWindow": null, 36 | "nextScheduleChange": null, 37 | "nextTimeBlock": { 38 | "start": "2020-03-07T04:00:00.000Z" 39 | }, 40 | "link": { 41 | "state": "ONLINE" 42 | }, 43 | "activityDataPoints": { 44 | "acPower": { 45 | "timestamp": "2020-03-06T17:38:30.302Z", 46 | "type": "POWER", 47 | "value": "OFF" 48 | } 49 | }, 50 | "sensorDataPoints": { 51 | "insideTemperature": { 52 | "celsius": 21.40, 53 | "fahrenheit": 70.52, 54 | "timestamp": "2020-03-06T18:06:09.546Z", 55 | "type": "TEMPERATURE", 56 | "precision": { 57 | "celsius": 0.1, 58 | "fahrenheit": 0.1 59 | } 60 | }, 61 | "humidity": { 62 | "type": "PERCENTAGE", 63 | "percentage": 50.40, 64 | "timestamp": "2020-03-06T18:06:09.546Z" 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | We take security vulnerabilities seriously. The following table outlines the versions of this project currently 6 | supported with security updates: 7 | 8 | | Version | Supported | 9 | |---------|--------------------| 10 | | 1.x | :white_check_mark: | 11 | | < 1.0 | :x: | 12 | 13 | ## Reporting a Vulnerability 14 | 15 | If you discover a security vulnerability, please contact us as soon as possible. We appreciate your efforts in 16 | responsibly disclosing vulnerabilities to help keep the project and its users safe. 17 | 18 | ### How to Report 19 | 20 | - **Email:** [wolfgang@malgadey.de](mailto:wolfgang@malgadey.de) 21 | 22 | Please include the following details to help us address the issue promptly: 23 | 24 | - A clear description of the vulnerability and its potential impact. 25 | - Steps to reproduce the vulnerability. 26 | - Any relevant logs, screenshots, or supporting details. 27 | 28 | We kindly ask that you do **not** disclose the vulnerability publicly until we have addressed it and released a patch. 29 | 30 | ## Response Time 31 | 32 | We will acknowledge receipt of your report within **72 hours**. After our initial assessment, we will provide updates 33 | on remediation progress as we work toward releasing a fix. We aim to issue a patch or provide a mitigation strategy 34 | within **14 days** of confirming a legitimate vulnerability. 35 | 36 | ## Disclosure Policy 37 | 38 | Once the vulnerability has been resolved, and a patch or mitigation has been made available, we will: 39 | 40 | 1. Notify the reporter with details on the fix and release timeline. 41 | 2. Credit the reporter in the release notes (if requested and appropriate). 42 | 3. Publicly disclose the nature of the vulnerability, its impact, and mitigation or patch information. 43 | 44 | ## Thank You 45 | 46 | Your efforts to secure this project are greatly appreciated. Thank you for helping us maintain a safe and reliable 47 | environment for our users. 48 | -------------------------------------------------------------------------------- /tests/fixtures/my_api_issue_88.termination_condition.json: -------------------------------------------------------------------------------- 1 | { 2 | "tadoMode": "HOME", 3 | "geolocationOverride": false, 4 | "geolocationOverrideDisableTime": null, 5 | "preparation": null, 6 | "setting": { 7 | "type": "HEATING", 8 | "power": "ON", 9 | "temperature": { 10 | "celsius": 22.00, 11 | "fahrenheit": 71.60 12 | } 13 | }, 14 | "overlayType": "MANUAL", 15 | "overlay": { 16 | "type": "MANUAL", 17 | "setting": { 18 | "type": "HEATING", 19 | "power": "ON", 20 | "temperature": { 21 | "celsius": 22.00, 22 | "fahrenheit": 71.60 23 | } 24 | }, 25 | "termination": { 26 | "type": "TIMER", 27 | "typeSkillBasedApp": "TIMER", 28 | "durationInSeconds": 1433, 29 | "expiry": "2024-12-19T14:38:04Z", 30 | "remainingTimeInSeconds": 1300, 31 | "projectedExpiry": "2024-12-19T14:38:04Z" 32 | } 33 | }, 34 | "openWindow": null, 35 | "nextScheduleChange": null, 36 | "nextTimeBlock": { 37 | "start": "2024-12-20T07:30:00.000Z" 38 | }, 39 | "link": { 40 | "state": "ONLINE" 41 | }, 42 | "runningOfflineSchedule": false, 43 | "activityDataPoints": { 44 | "heatingPower": { 45 | "type": "PERCENTAGE", 46 | "percentage": 100.00, 47 | "timestamp": "2024-12-19T14:14:15.558Z" 48 | } 49 | }, 50 | "sensorDataPoints": { 51 | "insideTemperature": { 52 | "celsius": 16.20, 53 | "fahrenheit": 61.16, 54 | "timestamp": "2024-12-19T14:14:52.404Z", 55 | "type": "TEMPERATURE", 56 | "precision": { 57 | "celsius": 0.1, 58 | "fahrenheit": 0.1 59 | } 60 | }, 61 | "humidity": { 62 | "type": "PERCENTAGE", 63 | "percentage": 64.40, 64 | "timestamp": "2024-12-19T14:14:52.404Z" 65 | } 66 | }, 67 | "type": "MANUAL", 68 | "termination": { 69 | "type": "TIMER", 70 | "typeSkillBasedApp": "TIMER", 71 | "durationInSeconds": 1300, 72 | "expiry": "2024-12-19T14:38:09Z", 73 | "remainingTimeInSeconds": 1299, 74 | "projectedExpiry": "2024-12-19T14:38:09Z" 75 | }, 76 | "terminationCondition": { 77 | "type": "TIMER", 78 | "durationInSeconds": 1300 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /PyTado/const.py: -------------------------------------------------------------------------------- 1 | """Constant values for the Tado component.""" 2 | 3 | from PyTado.types import FanLevel, FanSpeed, HvacAction, HvacMode 4 | 5 | # API client ID 6 | CLIENT_ID_DEVICE = "1bb50063-6b0c-4d11-bd99-387f4a91cc46" # nosec B105 7 | 8 | 9 | CONST_LINK_OFFLINE = "OFFLINE" 10 | CONST_CONNECTION_OFFLINE = "OFFLINE" 11 | 12 | 13 | FAN_SPEED_TO_FAN_LEVEL = { 14 | FanSpeed.OFF: FanLevel.OFF, 15 | FanSpeed.AUTO: FanLevel.AUTO, 16 | FanSpeed.LOW: FanLevel.LEVEL1, 17 | FanSpeed.MIDDLE: FanLevel.LEVEL2, 18 | FanSpeed.HIGH: FanLevel.LEVEL3, 19 | } 20 | 21 | # When we change the temperature setting, we need an overlay mode 22 | CONST_OVERLAY_TADO_MODE = ( 23 | "NEXT_TIME_BLOCK" # wait until tado changes the mode automatic 24 | ) 25 | CONST_OVERLAY_MANUAL = "MANUAL" # the user has changed the temperature or mode manually 26 | CONST_OVERLAY_TIMER = "TIMER" # the temperature will be reset after a timespan 27 | 28 | # Heat always comes first since we get the 29 | # min and max tempatures for the zone from 30 | # it. 31 | # Heat is preferred as it generally has a lower minimum temperature 32 | ORDERED_KNOWN_TADO_MODES = [ 33 | HvacMode.HEAT, 34 | HvacMode.COOL, 35 | HvacMode.AUTO, 36 | HvacMode.DRY, 37 | HvacMode.FAN, 38 | ] 39 | 40 | TADO_MODES_TO_HVAC_ACTION: dict[HvacMode, HvacAction] = { 41 | HvacMode.HEAT: HvacAction.HEATING, 42 | HvacMode.DRY: HvacAction.DRYING, 43 | HvacMode.FAN: HvacAction.FAN, 44 | HvacMode.COOL: HvacAction.COOLING, 45 | } 46 | 47 | TADO_HVAC_ACTION_TO_MODES: dict[HvacAction, HvacMode | HvacAction] = { 48 | HvacAction.HEATING: HvacMode.HEAT, 49 | HvacAction.HOT_WATER: HvacAction.HEATING, 50 | HvacAction.DRYING: HvacMode.DRY, 51 | HvacAction.FAN: HvacMode.FAN, 52 | HvacAction.COOLING: HvacMode.COOL, 53 | } 54 | 55 | # These modes will not allow a temp to be set 56 | TADO_MODES_WITH_NO_TEMP_SETTING = [ 57 | HvacMode.AUTO, 58 | HvacMode.DRY, 59 | HvacMode.FAN, 60 | ] 61 | 62 | DEFAULT_TADO_PRECISION = 0.1 63 | DEFAULT_TADOX_PRECISION = 0.01 64 | DEFAULT_DATE_FORMAT = "%Y-%m-%d" 65 | 66 | DEFAULT_TADOX_MIN_TEMP = 5.0 67 | DEFAULT_TADOX_MAX_TEMP = 30.0 68 | 69 | HOME_DOMAIN = "homes" 70 | DEVICE_DOMAIN = "devices" 71 | 72 | HTTP_CODES_OK = [200, 201, 202, 204] 73 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | fail_fast: true 2 | 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v5.0.0 6 | hooks: 7 | - id: no-commit-to-branch 8 | name: "Don't commit to master branch" 9 | args: [--branch, master] 10 | - id: check-ast 11 | - id: check-json 12 | - id: check-merge-conflict 13 | - id: check-toml 14 | - id: check-yaml 15 | - id: check-json 16 | - id: end-of-file-fixer 17 | - id: mixed-line-ending 18 | - id: trailing-whitespace 19 | 20 | 21 | - repo: https://github.com/PyCQA/isort 22 | rev: 6.0.1 23 | hooks: 24 | - id: isort 25 | exclude: tests/ 26 | args: ["--profile", "black"] 27 | 28 | - repo: https://github.com/hhatto/autopep8 29 | rev: v2.3.2 30 | hooks: 31 | - id: autopep8 32 | exclude: tests/ 33 | args: [--max-line-length=100, --in-place, --aggressive] 34 | 35 | - repo: https://github.com/PyCQA/flake8 36 | rev: 7.2.0 37 | hooks: 38 | - id: flake8 39 | exclude: tests/ 40 | args: [--max-line-length=100] 41 | 42 | - repo: https://github.com/asottile/pyupgrade 43 | rev: v3.19.1 44 | hooks: 45 | - id: pyupgrade 46 | 47 | - repo: https://github.com/PyCQA/bandit 48 | rev: '1.8.3' 49 | hooks: 50 | - id: bandit 51 | args: ["-c", "pyproject.toml"] 52 | additional_dependencies: ["bandit[toml]"] 53 | 54 | - repo: https://github.com/astral-sh/ruff-pre-commit 55 | rev: v0.11.3 56 | hooks: 57 | - id: ruff 58 | exclude: tests/ 59 | args: [--line-length=100, --fix] 60 | 61 | # Teporarily disabled as many types need to be improved 62 | # - repo: https://github.com/pre-commit/mirrors-mypy 63 | # rev: v1.15.0 64 | # hooks: 65 | # - id: mypy 66 | # exclude: tests/ 67 | # additional_dependencies: [types-requests] 68 | 69 | - repo: local 70 | hooks: 71 | - id: prettier 72 | name: prettier 73 | entry: prettier 74 | language: system 75 | types: [python, json, yaml, markdown] 76 | 77 | - id: pytest 78 | name: pytest 79 | entry: pytest 80 | language: python 81 | types: [python] 82 | pass_filenames: false 83 | always_run: true 84 | additional_dependencies: [responses, pydantic, pytest-mock, pytest-socket] 85 | -------------------------------------------------------------------------------- /tests/common.py: -------------------------------------------------------------------------------- 1 | """Common utils for tests.""" 2 | 3 | import os 4 | import unittest 5 | from unittest import mock 6 | 7 | from typing_extensions import Never 8 | 9 | from PyTado.http import DeviceActivationStatus, Http 10 | from PyTado.interface.api.hops_tado import TadoX 11 | from PyTado.interface.api.my_tado import Tado 12 | 13 | 14 | def load_fixture(filename: str) -> str: 15 | """Load a fixture.""" 16 | path = os.path.join(os.path.dirname(__file__), "fixtures", filename) 17 | with open(path) as fd: 18 | return fd.read() 19 | 20 | 21 | class TadoBaseTestCase(unittest.TestCase): 22 | """Test cases for tado class""" 23 | 24 | is_x_line: bool = False 25 | tado_client: Tado | TadoX 26 | http: Http 27 | 28 | def __init_subclass__(cls, is_x_line: bool = False, **kwargs: Never) -> None: 29 | """Initialize the test case class.""" 30 | super().__init_subclass__(**kwargs) 31 | cls.is_x_line = is_x_line 32 | 33 | def setUp(self) -> None: 34 | super().setUp() 35 | 36 | login_patch = mock.patch("PyTado.http.Http._login_device_flow") 37 | device_activation_patch = mock.patch("PyTado.http.Http.device_activation") 38 | is_x_line_patch = mock.patch( 39 | "PyTado.http.Http._check_x_line_generation", return_value=self.is_x_line 40 | ) 41 | get_me_patch = mock.patch("PyTado.interface.api.Tado.get_me") 42 | device_activation_status_patch = mock.patch( 43 | "PyTado.http.Http.device_activation_status", 44 | DeviceActivationStatus.COMPLETED, 45 | ) 46 | 47 | login_patch.start() 48 | device_activation_patch.start() 49 | is_x_line_patch.start() 50 | get_me_patch.start() 51 | device_activation_status_patch.start() 52 | self.addCleanup(device_activation_status_patch.stop) 53 | self.addCleanup(login_patch.stop) 54 | self.addCleanup(device_activation_patch.stop) 55 | self.addCleanup(is_x_line_patch.stop) 56 | self.addCleanup(get_me_patch.stop) 57 | self.http = Http() 58 | self.http.device_activation() 59 | self.http._x_api = self.is_x_line 60 | self.http._id = 1234 61 | if self.is_x_line: 62 | self.tado_client = TadoX.from_http(self.http) 63 | else: 64 | self.tado_client = Tado.from_http(self.http) 65 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Test multiple Python versions 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | get-python-version: 14 | runs-on: ubuntu-latest 15 | outputs: 16 | python-version: ${{ steps.python-version.outputs.python_version }} 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v6 20 | 21 | - name: Read .python-version 22 | id: python-version 23 | run: | 24 | python_version=$(cat .python-version) 25 | echo "python_version=$python_version" >> "$GITHUB_OUTPUT" 26 | echo "Python version found: $python_version" 27 | 28 | test: 29 | runs-on: ubuntu-latest 30 | needs: get-python-version 31 | strategy: 32 | matrix: 33 | python-version: ["3.13", "3.12", "3.11"] 34 | steps: 35 | - uses: actions/checkout@v6 36 | 37 | - name: Set up Python ${{ matrix.python-version }} 38 | uses: actions/setup-python@v6 39 | with: 40 | python-version: ${{ matrix.python-version }} 41 | 42 | - name: Cache dependencies 43 | uses: actions/cache@v4 44 | with: 45 | path: ${{ env.pythonLocation }} 46 | key: ${{ runner.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml') }} 47 | 48 | - name: Install dependencies 49 | run: | 50 | python -m pip install --upgrade pip 51 | pip install -e '.[all]' 52 | 53 | - name: Run Tests with coverage 54 | run: | 55 | pytest --cov --junitxml=junit.xml -o junit_family=legacy --cov-branch --cov-report=xml 56 | 57 | - name: Upload test results to Codecov 58 | if: ${{ !cancelled() && matrix.python-version == needs.get-python-version.outputs.python-version }} 59 | uses: codecov/test-results-action@v1 60 | with: 61 | token: ${{ secrets.CODECOV_TOKEN }} 62 | 63 | - name: Upload coverage reports to Codecov 64 | if: ${{ !cancelled() && matrix.python-version == needs.get-python-version.outputs.python-version }} 65 | uses: codecov/codecov-action@v5 66 | with: 67 | token: ${{ secrets.CODECOV_TOKEN }} 68 | -------------------------------------------------------------------------------- /tests/fixtures/running_times.json: -------------------------------------------------------------------------------- 1 | { 2 | "lastUpdated": "2023-08-05T19:50:21Z", 3 | "runningTimes": [ 4 | { 5 | "endTime": "2023-08-02 00:00:00", 6 | "runningTimeInSeconds": 0, 7 | "startTime": "2023-08-01 00:00:00", 8 | "zones": [ 9 | { 10 | "id": 1, 11 | "runningTimeInSeconds": 1 12 | }, 13 | { 14 | "id": 2, 15 | "runningTimeInSeconds": 2 16 | }, 17 | { 18 | "id": 3, 19 | "runningTimeInSeconds": 3 20 | }, 21 | { 22 | "id": 4, 23 | "runningTimeInSeconds": 4 24 | } 25 | ] 26 | }, 27 | { 28 | "endTime": "2023-08-03 00:00:00", 29 | "runningTimeInSeconds": 0, 30 | "startTime": "2023-08-02 00:00:00", 31 | "zones": [ 32 | { 33 | "id": 1, 34 | "runningTimeInSeconds": 5 35 | }, 36 | { 37 | "id": 2, 38 | "runningTimeInSeconds": 6 39 | }, 40 | { 41 | "id": 3, 42 | "runningTimeInSeconds": 7 43 | }, 44 | { 45 | "id": 4, 46 | "runningTimeInSeconds": 8 47 | } 48 | ] 49 | }, 50 | { 51 | "endTime": "2023-08-04 00:00:00", 52 | "runningTimeInSeconds": 0, 53 | "startTime": "2023-08-03 00:00:00", 54 | "zones": [ 55 | { 56 | "id": 1, 57 | "runningTimeInSeconds": 9 58 | }, 59 | { 60 | "id": 2, 61 | "runningTimeInSeconds": 10 62 | }, 63 | { 64 | "id": 3, 65 | "runningTimeInSeconds": 11 66 | }, 67 | { 68 | "id": 4, 69 | "runningTimeInSeconds": 12 70 | } 71 | ] 72 | } 73 | ], 74 | "summary": { 75 | "endTime": "2023-08-06 00:00:00", 76 | "meanInSecondsPerDay": 24, 77 | "startTime": "2023-08-01 00:00:00", 78 | "totalRunningTimeInSeconds": 120 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry-core>=1.0.0"] 3 | build-backend = "poetry.core.masonry.api" 4 | 5 | [tool.poetry] 6 | name = "python-tado" 7 | version = "1.0.0" 8 | description = "PyTado from chrism0dwk, modified by w.malgadey, diplix, michaelarnauts, LenhartStephan, splifter, syssi, andersonshatch, Yippy, p0thi, Coffee2CodeNL, chiefdragon, FilBr, nikilase, albertomontesg, Moritz-Schmidt, palazzem" 9 | authors = [ 10 | "Chris Jewell ", 11 | "w.malgadey ", 12 | "FilBr ", 13 | "Erwin Douna " 14 | ] 15 | classifiers = [ 16 | "Development Status :: 4 - Beta", 17 | "Topic :: Home Automation", 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 20 | "Natural Language :: English", 21 | "Programming Language :: Python :: 3", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | "Programming Language :: Python :: 3.13" 25 | ] 26 | keywords = ["tado"] 27 | readme = "README.md" 28 | license = "GPL-3.0-or-later" 29 | documentation = "https://wmalgadey.github.io/PyTado/" 30 | homepage = "https://github.com/wmalgadey/PyTado" 31 | repository = "https://github.com/wmalgadey/PyTado" 32 | 33 | [tool.poetry.dependencies] 34 | python = ">=3.11" 35 | requests = "*" 36 | pylint = "*" 37 | pre-commit = "*" 38 | pytype = "*" 39 | types-requests = "*" 40 | responses = "*" 41 | pytest = "*" 42 | pytest-mock = "*" 43 | pytest-cov = "*" 44 | pytest-socket = "*" 45 | pydantic = "^2.10.6" 46 | pydoc-markdown = "*" 47 | 48 | [tool.poetry.extras] 49 | dev = ["pre-commit", "pytype", "types-requests"] 50 | lint = ["pylint"] 51 | test = ["responses", "pytest", "pytest-mock", "pytest-socket", "pytest-cov"] 52 | all = ["pre-commit", "pytype", "types-requests", "pylint", "responses", "pytest", "pytest-mock", "pytest-socket", "pytest-cov"] 53 | 54 | [tool.poetry.scripts] 55 | pytado = "PyTado.__main__:main" 56 | 57 | [[tool.poetry.packages]] 58 | include = "PyTado" 59 | from = "." 60 | 61 | [tool.poetry.group.dev.dependencies] 62 | mypy = "^1.15.0" 63 | 64 | [tool.coverage.report] 65 | show_missing = true 66 | 67 | [tool.coverage.run] 68 | plugins = ["covdefaults"] 69 | source = ["PyTado"] 70 | 71 | [tool.bandit] 72 | exclude_dirs = ["tests"] 73 | tests = [] 74 | skips = [] 75 | 76 | [tool.mypy] 77 | strict = true 78 | 79 | [[tool.pydoc-markdown.loaders]] 80 | type = "python" 81 | search_path = [ "../PyTado" ] 82 | 83 | [tool.pydoc-markdown.renderer] 84 | type = "markdown" 85 | -------------------------------------------------------------------------------- /PyTado/models/line_x/room.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from PyTado.models.line_x.device import Connection 4 | from PyTado.models.util import Base 5 | from PyTado.types import OverlayMode, Power 6 | 7 | 8 | class XOpenWindow(Base): 9 | """OpenWindow model represents the open window state of a room.""" 10 | 11 | activated: bool 12 | expiry_in_seconds: int 13 | 14 | 15 | class ManualControlTermination(Base): 16 | """ManualControlTermination model represents the manual control termination settings of a room. 17 | 18 | used in: RoomState""" 19 | 20 | type: OverlayMode 21 | remaining_time_in_seconds: int | None = None 22 | projected_expiry: datetime | None = None 23 | 24 | 25 | class NextTimeBlock(Base): 26 | """NextTimeBlock model represents the next time block.""" 27 | 28 | start: datetime 29 | 30 | 31 | class BalanceControl(Base): 32 | """BalanceControl model""" 33 | 34 | pass # TODO: I don't know what this is yet 35 | 36 | 37 | class InsideTemperature(Base): 38 | """InsideTemperature model represents the temperature in Celsius and Fahrenheit.""" 39 | 40 | value: float 41 | 42 | 43 | class Setting(Base): 44 | """Setting model represents the setting of a room.""" 45 | 46 | power: Power 47 | temperature: InsideTemperature | None = None 48 | 49 | 50 | class Humidity(Base): 51 | """Humidity model represents the humidity in percent.""" 52 | 53 | percentage: float 54 | 55 | 56 | class SensorDataPoints(Base): 57 | """SensorDataPoints model represents the sensor data points of a room.""" 58 | 59 | inside_temperature: InsideTemperature 60 | humidity: Humidity 61 | 62 | 63 | class HeatingPower(Base): 64 | """HeatingPower model represents the heating power of a room.""" 65 | 66 | percentage: int 67 | 68 | 69 | class NextScheduleChange(Base): 70 | """NextScheduleChange model represents the next schedule change.""" 71 | 72 | start: datetime 73 | setting: Setting 74 | 75 | 76 | class RoomState(Base): 77 | """Room model (replaces Zones in TadoX) represents the state of a room.""" 78 | 79 | id: int 80 | name: str 81 | sensor_data_points: SensorDataPoints 82 | setting: Setting 83 | heating_power: HeatingPower 84 | connection: Connection 85 | open_window: XOpenWindow | None 86 | next_schedule_change: NextScheduleChange | None 87 | next_time_block: NextTimeBlock 88 | balance_control: str | None = None 89 | manual_control_termination: ManualControlTermination | None = None 90 | boost_mode: ManualControlTermination | None = None 91 | -------------------------------------------------------------------------------- /tests/fixtures/home_1234/my_api_v2_me.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Alice Wonderland", 3 | "email": "alice-in@wonder.land", 4 | "username": "alice-in@wonder.land", 5 | "id": "123a1234567b89012cde1f23", 6 | "homes": [ 7 | { 8 | "id": 1234, 9 | "name": "Test Home" 10 | } 11 | ], 12 | "locale": "de_DE", 13 | "mobileDevices": [ 14 | { 15 | "name": "iPad", 16 | "id": 1234567, 17 | "settings": { 18 | "geoTrackingEnabled": false, 19 | "specialOffersEnabled": true, 20 | "onDemandLogRetrievalEnabled": false, 21 | "pushNotifications": { 22 | "lowBatteryReminder": true, 23 | "awayModeReminder": true, 24 | "homeModeReminder": true, 25 | "openWindowReminder": true, 26 | "energySavingsReportReminder": true, 27 | "incidentDetection": true, 28 | "energyIqReminder": true, 29 | "tariffHighPriceAlert": true, 30 | "tariffLowPriceAlert": true 31 | } 32 | }, 33 | "deviceMetadata": { 34 | "platform": "iOS", 35 | "osVersion": "18.0", 36 | "model": "iPad8,10", 37 | "locale": "de" 38 | } 39 | }, 40 | { 41 | "name": "iPhone", 42 | "id": 12345678, 43 | "settings": { 44 | "geoTrackingEnabled": true, 45 | "specialOffersEnabled": true, 46 | "onDemandLogRetrievalEnabled": false, 47 | "pushNotifications": { 48 | "lowBatteryReminder": true, 49 | "awayModeReminder": true, 50 | "homeModeReminder": true, 51 | "openWindowReminder": true, 52 | "energySavingsReportReminder": true, 53 | "incidentDetection": true, 54 | "energyIqReminder": true, 55 | "tariffHighPriceAlert": true, 56 | "tariffLowPriceAlert": true 57 | } 58 | }, 59 | "location": { 60 | "stale": false, 61 | "atHome": true, 62 | "bearingFromHome": { 63 | "degrees": 90.0, 64 | "radians": 1.5707963267948966 65 | }, 66 | "relativeDistanceFromHomeFence": 0.0 67 | }, 68 | "deviceMetadata": { 69 | "platform": "iOS", 70 | "osVersion": "18.2", 71 | "model": "iPhone14,5", 72 | "locale": "de" 73 | } 74 | } 75 | ] 76 | } 77 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "customizations": { 3 | "codespaces": { 4 | "openFiles": [ 5 | "README.md", 6 | "CONTRIBUTING.md" 7 | ] 8 | }, 9 | "vscode": { 10 | "extensions": [ 11 | "ms-python.python", 12 | "redhat.vscode-yaml", 13 | "esbenp.prettier-vscode", 14 | "GitHub.vscode-pull-request-github", 15 | "charliermarsh.ruff", 16 | "GitHub.vscode-github-actions", 17 | "ryanluker.vscode-coverage-gutters" 18 | ], 19 | "settings": { 20 | "[python]": { 21 | "editor.codeActionsOnSave": { 22 | "source.fixAll": "always", 23 | "source.organizeImports": "always" 24 | } 25 | }, 26 | "coverage-gutters.customizable.context-menu": true, 27 | "coverage-gutters.customizable.status-bar-toggler-watchCoverageAndVisibleEditors-enabled": true, 28 | "coverage-gutters.showGutterCoverage": false, 29 | "coverage-gutters.showLineCoverage": true, 30 | "coverage-gutters.xmlname": "coverage.xml", 31 | "python.analysis.extraPaths": [ 32 | "${workspaceFolder}/src" 33 | ], 34 | "python.defaultInterpreterPath": ".venv/bin/python", 35 | "python.formatting.provider": "black", 36 | "python.linting.enabled": true, 37 | "python.linting.mypyEnabled": true, 38 | "python.linting.pylintEnabled": true, 39 | "python.testing.cwd": "${workspaceFolder}", 40 | "python.testing.pytestArgs": [ 41 | "--cov-report=xml" 42 | ], 43 | "python.testing.pytestEnabled": true, 44 | "ruff.importStrategy": "fromEnvironment", 45 | "ruff.interpreter": [ 46 | ".venv/bin/python" 47 | ], 48 | "terminal.integrated.defaultProfile.linux": "zsh" 49 | } 50 | } 51 | }, 52 | "features": { 53 | "ghcr.io/devcontainers-extra/features/poetry:2": {}, 54 | "ghcr.io/devcontainers/features/github-cli:1": {}, 55 | "ghcr.io/devcontainers/features/node:1": {}, 56 | "ghcr.io/devcontainers/features/python:1": { 57 | "installTools": false 58 | } 59 | }, 60 | "image": "mcr.microsoft.com/vscode/devcontainers/python:3.13", 61 | "name": "PyTado", 62 | "postStartCommand": "bash scripts/bootstrap", 63 | "updateContentCommand": "bash scripts/bootstrap", 64 | "containerUser": "vscode", 65 | "remoteUser": "vscode", 66 | "updateRemoteUserUID": true, 67 | "containerEnv": { 68 | "HOME": "/home/vscode" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /PyTado/factory/initializer.py: -------------------------------------------------------------------------------- 1 | """ 2 | PyTado API factory to use app.tado.com or hops.tado.com 3 | """ 4 | 5 | from typing import Self 6 | 7 | import requests 8 | 9 | import PyTado.interface.api as API 10 | from PyTado.exceptions import TadoException 11 | from PyTado.http import DeviceActivationStatus, Http 12 | 13 | 14 | class TadoClientInitializer: 15 | """Class to authenticate and get the Tado client.""" 16 | 17 | http: Http 18 | debug: bool = False 19 | token_file_path: str | None = None 20 | saved_refresh_token: str | None = None 21 | http_session: requests.Session | None = None 22 | 23 | def __init__( 24 | self, 25 | token_file_path: str | None = None, 26 | saved_refresh_token: str | None = None, 27 | http_session: requests.Session | None = None, 28 | debug: bool = False, 29 | ): 30 | self.token_file_path = token_file_path 31 | self.saved_refresh_token = saved_refresh_token 32 | self.http_session = http_session 33 | self.debug = debug 34 | self.http = Http( 35 | token_file_path=token_file_path, 36 | saved_refresh_token=saved_refresh_token, 37 | http_session=http_session, 38 | debug=debug, 39 | ) 40 | 41 | def get_verification_url(self) -> str | None: 42 | """Returns the URL for device verification.""" 43 | if self.http.device_activation_status == DeviceActivationStatus.NOT_STARTED: 44 | self.device_activation() 45 | 46 | return self.http.device_verification_url 47 | 48 | def device_activation(self) -> Self: 49 | """Activates the device. 50 | 51 | Caution: this method will block until the device is activated or the timeout is reached. 52 | """ 53 | self.http.device_activation() 54 | 55 | return self 56 | 57 | def get_client(self) -> API.TadoX | API.Tado: 58 | """Returns the client instance after device activation.""" 59 | if self.http.device_activation_status == DeviceActivationStatus.COMPLETED: 60 | if self.http.is_x_line: 61 | return API.TadoX.from_http(http=self.http, debug=self.debug) 62 | 63 | return API.Tado.from_http(http=self.http, debug=self.debug) 64 | 65 | raise TadoException( 66 | "Authentication failed. Please check the device verification URL and try again." 67 | ) 68 | 69 | def authenticate_and_get_client(self) -> API.TadoX | API.Tado: 70 | """Authenticate and get the client instance, prompting for device activation if needed.""" 71 | print("Starting device activation process...") 72 | print("Device activation status: ", self.http.device_activation_status) 73 | 74 | if self.http.device_activation_status != DeviceActivationStatus.COMPLETED: 75 | print("Click on the link to log in to your Tado account.") 76 | print("Device verification URL: ", self.get_verification_url()) 77 | 78 | self.device_activation() 79 | 80 | if ( 81 | self.http.device_activation_status == DeviceActivationStatus.COMPLETED 82 | ): # pyright: ignore[reportUnnecessaryComparison] 83 | print("Device activation completed successfully.") 84 | else: 85 | raise TadoException( 86 | "Device activation failed. " 87 | "Please check the device verification URL and try again." 88 | ) 89 | 90 | return self.get_client() 91 | -------------------------------------------------------------------------------- /PyTado/models/util.py: -------------------------------------------------------------------------------- 1 | """Utility module providing base model and validation functionality for PyTado. 2 | 3 | This module defines the foundational model infrastructure used throughout the PyTado library, 4 | centered around Pydantic for data validation and serialization. It includes: 5 | 6 | - A Base model class with customized configuration for: 7 | - Automatic camelCase/snake_case field name conversion 8 | - Flexible extra field handling 9 | - JSON serialization utilities 10 | - Debug-focused validation wrapper that logs: 11 | - Extra fields present in the data but not in the model 12 | - Fields defined in the model but not present in the data 13 | - Validation errors with detailed context 14 | 15 | This module serves as the backbone for all data models in PyTado, ensuring consistent 16 | handling of API data and providing helpful debugging information during development. 17 | """ 18 | 19 | from typing import Any, Self 20 | 21 | from pydantic import ( 22 | AliasChoices, 23 | AliasGenerator, 24 | BaseModel, 25 | ConfigDict, 26 | ModelWrapValidatorHandler, 27 | ValidationError, 28 | model_validator, 29 | ) 30 | from pydantic.alias_generators import to_camel 31 | 32 | from PyTado.logger import Logger 33 | 34 | LOGGER = Logger(__name__) 35 | 36 | 37 | class Base(BaseModel): 38 | """Base model for all models in PyTado. 39 | 40 | Provides a custom alias generator that converts snake_case to camelCase for 41 | serialization, and CamelCase to snake_case for validation. 42 | Also provides a helper method to dump the model to a JSON string or python dict 43 | with the correct aliases and some debug logging for model validation. 44 | """ 45 | 46 | model_config = ConfigDict( 47 | extra="allow", 48 | alias_generator=AliasGenerator( 49 | validation_alias=lambda field_name: AliasChoices( 50 | field_name, to_camel(field_name) 51 | ), 52 | serialization_alias=to_camel, 53 | ), 54 | ) 55 | 56 | def to_json(self) -> str: 57 | return self.model_dump_json(by_alias=True) 58 | 59 | def to_dict(self) -> dict[str, Any]: 60 | return self.model_dump(by_alias=True) 61 | 62 | @model_validator(mode="wrap") 63 | @classmethod 64 | def log_failed_validation( 65 | cls, data: Any, handler: ModelWrapValidatorHandler[Self] 66 | ) -> Self: 67 | """Model validation debug helper. 68 | Logs in the following cases: 69 | - (Debug) Keys in data that are not in the model 70 | - (Debug) Keys in the model that are not in the data 71 | - (Error) Validation errors 72 | (This is just for debugging and development, can be removed if not needed anymore) 73 | """ 74 | try: 75 | model: Self = handler(data) 76 | 77 | extra = model.model_extra 78 | 79 | if extra: 80 | for key, value in extra.items(): 81 | if value is not None: 82 | LOGGER.warning( 83 | "Model %s has extra key: %s with value %r", cls, key, value 84 | ) 85 | 86 | unused_keys = model.model_fields.keys() - model.model_fields_set 87 | if unused_keys: 88 | LOGGER.debug("Model %s has unused keys: %r", cls, unused_keys) 89 | 90 | return model 91 | except ValidationError: 92 | LOGGER.error("Model %s failed to validate with data %r", cls, data) 93 | raise 94 | -------------------------------------------------------------------------------- /CONTRIBUTION.md: -------------------------------------------------------------------------------- 1 | # Contributing to PyTado 2 | 3 | Thank you for considering contributing to PyTado! This document provides guidelines to help you get started with your 4 | contributions. Please follow the instructions below to ensure a smooth contribution process. 5 | 6 | 1. Prepare your [development environment](https://github.com/wmalgadey/PyTado#development). 7 | 2. Ensure that you have installed the `pre-commit` hooks. 8 | 9 | By following these steps, you can ensure that your contributions are of the highest quality and are properly tested 10 | before they are merged into the project. 11 | 12 | ## Issues 13 | 14 | If you encounter a problem or have a suggestion, please open a [new issue](https://github.com/wmalgadey/PyTado/issues/new/choose). 15 | Select the most appropriate type from the options provided: 16 | 17 | - **Bug Report**: If you've identified an issue with an existing feature that isn't performing as documented or expected, 18 | please select this option. This will help us identify and rectify problems more efficiently. 19 | 20 | - **Feature Request**: If you have an idea for a new feature or an enhancement to the current ones, select this option. 21 | Additionally, if you feel that a certain feature could be optimized or modified to better suit specific scenarios, this 22 | is the right category to bring it to our attention. 23 | 24 | - **General Question**: If you are unsure or have a general question, please join our 25 | [GitHub Discussions](https://github.com/wmalgadey/PyTado/discussions). 26 | 27 | - **Direct Communication**: 28 | 29 | 30 | After choosing an issue type, a pre-formatted template will appear. Provide as much detail as possible within this 31 | template. Your insights and contributions help improve the project, and we genuinely appreciate your effort. 32 | 33 | ## Pull Requests 34 | 35 | ### PR Title 36 | 37 | We follow the [conventional commit convention](https://www.conventionalcommits.org/en/v1.0.0/) for our PR titles. The 38 | title should adhere to the structure below: 39 | 40 | ```text 41 | [optional scope]: 42 | ``` 43 | 44 | The common types are: 45 | 46 | - `feat` (enhancements) 47 | - `fix` (bug fixes) 48 | - `docs` (documentation changes) 49 | - `perf` (performance improvements) 50 | - `refactor` (major code refactorings) 51 | - `tests` (changes to tests) 52 | - `tools` (changes to package spec or tools in general) 53 | - `ci` (changes to our CI) 54 | - `deps` (changes to dependencies) 55 | 56 | If your change breaks backwards compatibility, indicate so by adding `!` after the type. 57 | 58 | Examples: 59 | 60 | - `feat(cli): add Transcribe command` 61 | - `fix: ensure hashing function returns correct value for random input` 62 | - `feat!: remove deprecated API` (a change that breaks backwards compatibility) 63 | 64 | ### PR Description 65 | 66 | After opening a new pull request, a pre-formatted template will appear. Provide as much detail as possible within this 67 | template. A good description can speed up the review process to get your code merged. 68 | 69 | ## Code of Conduct 70 | 71 | Please note that this project is released with a [Contributor Code of Conduct](https://www.contributor-covenant.org/version/2/0/code_of_conduct/). 72 | By participating in this project, you agree to abide by its terms. 73 | 74 | Thank you for your contributions! 75 | -------------------------------------------------------------------------------- /PyTado/models/historic.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Generic, TypeVar 3 | 4 | from pydantic import Field 5 | 6 | from PyTado.models.util import Base 7 | from PyTado.types import Power, StrEnumMissing, ZoneType 8 | 9 | T = TypeVar("T") 10 | 11 | 12 | class Interval(Base): 13 | # from is a reserved keyword in Python so we need to use alias 14 | from_date: datetime = Field(validation_alias="from", serialization_alias="from") 15 | to_date: datetime = Field(validation_alias="to", serialization_alias="to") 16 | 17 | 18 | class DataInterval(Interval, Generic[T]): 19 | value: T 20 | 21 | 22 | class TimeSeriesType(StrEnumMissing): 23 | dataPoints = "dataPoints" 24 | dataIntervals = "dataIntervals" 25 | slots = "slots" 26 | 27 | 28 | class ValueType(StrEnumMissing): 29 | boolean = "boolean" 30 | temperature = "temperature" 31 | humidity = "percentage" 32 | stripes = "stripes" 33 | heatingSetting = "heatingSetting" 34 | callForHeat = "callForHeat" 35 | weatherCondition = "weatherCondition" 36 | 37 | 38 | class HumidityPercentageUnit(StrEnumMissing): 39 | UNIT_INTERVAL = "UNIT_INTERVAL" 40 | 41 | 42 | class StripeType(StrEnumMissing): 43 | AWAY = "AWAY" 44 | OVERLAY_ACTIVE = "OVERLAY_ACTIVE" 45 | HOME = "HOME" 46 | OPEN_WINDOW_DETECTED = "OPEN_WINDOW_DETECTED" 47 | MEASURING_DEVICE_DISCONNECTED = "MEASURING_DEVICE_DISCONNECTED" 48 | 49 | 50 | class WeatherConditionType(StrEnumMissing): 51 | CLOUDY = "CLOUDY" 52 | CLOUDY_MOSTLY = "CLOUDY_MOSTLY" 53 | NIGHT_CLOUDY = "NIGHT_CLOUDY" 54 | NIGHT_CLEAR = "NIGHT_CLEAR" 55 | CLOUDY_PARTLY = "CLOUDY_PARTLY" 56 | SUN = "SUN" 57 | RAIN = "RAIN" 58 | SCATTERED_RAIN = "SCATTERED_RAIN" 59 | SNOW = "SNOW" 60 | SCATTERED_SNOW = "SCATTERED_SNOW" 61 | 62 | 63 | class DataBase(Base): 64 | time_series_type: TimeSeriesType 65 | value_type: ValueType 66 | 67 | 68 | class DataPoint(Base, Generic[T]): 69 | timestamp: datetime 70 | value: T 71 | 72 | 73 | class DataPointBase(DataBase, Generic[T]): 74 | min: T | None = None 75 | max: T | None = None 76 | data_points: list[DataPoint[T]] | None = None 77 | 78 | 79 | class Humidity(DataPointBase[float]): 80 | percentage_unit: HumidityPercentageUnit 81 | 82 | 83 | class DataIntervalBase(DataBase, Generic[T]): 84 | data_intervals: list[DataInterval[T]] 85 | 86 | 87 | class TempValue(Base): 88 | celsius: float 89 | fahrenheit: float 90 | 91 | 92 | class MeasuredData(Base): 93 | measuring_device_connected: DataIntervalBase[bool] 94 | inside_temperature: DataPointBase[TempValue] 95 | humidity: Humidity 96 | 97 | 98 | class Setting(Base): 99 | type: ZoneType 100 | power: Power 101 | temperature: TempValue | None 102 | 103 | 104 | class StripeValue(Base): 105 | stripe_type: StripeType 106 | setting: Setting | None = None 107 | 108 | 109 | class WeatherConditionValue(Base): 110 | state: WeatherConditionType 111 | temperature: TempValue 112 | 113 | 114 | class SlotValue(Base): 115 | state: WeatherConditionType 116 | temperature: TempValue 117 | 118 | 119 | class Slots(DataBase): 120 | slots: dict[str, SlotValue] 121 | 122 | 123 | class Weather(Base): 124 | condition: DataIntervalBase[WeatherConditionValue] 125 | sunny: DataIntervalBase[bool] 126 | slots: Slots 127 | 128 | 129 | class Historic(Base): 130 | zone_type: ZoneType 131 | interval: Interval 132 | hours_in_day: int 133 | measured_data: MeasuredData 134 | stripes: DataIntervalBase[StripeValue] 135 | settings: DataIntervalBase[Setting] 136 | call_for_heat: DataIntervalBase[str] 137 | weather: Weather 138 | -------------------------------------------------------------------------------- /eleventy.config.js: -------------------------------------------------------------------------------- 1 | import { IdAttributePlugin, InputPathToUrlTransformPlugin, HtmlBasePlugin, EleventyRenderPlugin } from "@11ty/eleventy"; 2 | import pluginSyntaxHighlight from "@11ty/eleventy-plugin-syntaxhighlight"; 3 | import pluginNavigation from "@11ty/eleventy-navigation"; 4 | 5 | import pluginFilters from "./docs/_config/filters.js"; 6 | 7 | /** @param {import("@11ty/eleventy").UserConfig} eleventyConfig */ 8 | export default async function (eleventyConfig) { 9 | eleventyConfig.setUseGitIgnore(false); 10 | 11 | // Drafts, see also _data/eleventyDataSchema.js 12 | eleventyConfig.addPreprocessor("drafts", "*", (data, content) => { 13 | if (data.draft && process.env.ELEVENTY_RUN_MODE === "build") { 14 | return false; 15 | } 16 | }); 17 | 18 | // Copy the contents of the `public` folder to the output folder 19 | // For example, `./public/css/` ends up in `_site/css/` 20 | eleventyConfig 21 | .addPassthroughCopy({ 22 | "./public/": "/" 23 | }); 24 | 25 | // Run Eleventy when these files change: 26 | // https://www.11ty.dev/docs/watch-serve/#add-your-own-watch-targets 27 | 28 | // Per-page bundles, see https://github.com/11ty/eleventy-plugin-bundle 29 | // Adds the {% css %} paired shortcode 30 | eleventyConfig.addBundle("css", { 31 | toFileDirectory: "css", 32 | }); 33 | // Adds the {% js %} paired shortcode 34 | eleventyConfig.addBundle("js", { 35 | toFileDirectory: "js", 36 | }); 37 | 38 | // Official plugins 39 | eleventyConfig.addPlugin(pluginSyntaxHighlight, { 40 | preAttributes: { tabindex: 0 } 41 | }); 42 | eleventyConfig.addPlugin(pluginNavigation); 43 | eleventyConfig.addPlugin(HtmlBasePlugin); 44 | eleventyConfig.addPlugin(InputPathToUrlTransformPlugin); 45 | eleventyConfig.addPlugin(EleventyRenderPlugin); 46 | 47 | // Filters 48 | eleventyConfig.addPlugin(pluginFilters); 49 | 50 | eleventyConfig.addPlugin(IdAttributePlugin, { 51 | // by default we use Eleventy’s built-in `slugify` filter: 52 | // slugify: eleventyConfig.getFilter("slugify"), 53 | // selector: "h1,h2,h3,h4,h5,h6", // default 54 | }); 55 | 56 | eleventyConfig.addShortcode("currentBuildDate", () => { 57 | return (new Date()).toISOString(); 58 | }); 59 | 60 | // Features to make your build faster (when you need them) 61 | 62 | // If your passthrough copy gets heavy and cumbersome, add this line 63 | // to emulate the file copy on the dev server. Learn more: 64 | // https://www.11ty.dev/docs/copy/#emulate-passthrough-copy-during-serve 65 | 66 | // eleventyConfig.setServerPassthroughCopyBehavior("passthrough"); 67 | }; 68 | 69 | export const config = { 70 | // Control which files Eleventy will process 71 | // e.g.: *.md, *.njk, *.html, *.liquid 72 | templateFormats: [ 73 | "md", 74 | "njk", 75 | "html", 76 | "liquid", 77 | "11ty.js", 78 | ], 79 | 80 | // Pre-process *.md files with: (default: `liquid`) 81 | markdownTemplateEngine: "njk", 82 | 83 | // Pre-process *.html files with: (default: `liquid`) 84 | htmlTemplateEngine: "njk", 85 | 86 | // These are all optional: 87 | dir: { 88 | input: "./docs/content", // default: "." 89 | includes: "../_includes", // default: "_includes" (`input` relative) 90 | data: "../_data", // default: "_data" (`input` relative) 91 | output: "_site" 92 | }, 93 | 94 | // ----------------------------------------------------------------- 95 | // Optional items: 96 | // ----------------------------------------------------------------- 97 | 98 | // If your site deploys to a subdirectory, change `pathPrefix`. 99 | // Read more: https://www.11ty.dev/docs/config/#deploy-to-a-subdirectory-with-a-path-prefix 100 | 101 | // When paired with the HTML plugin https://www.11ty.dev/docs/plugins/html-base/ 102 | // it will transform any absolute URLs in your HTML to include this 103 | // folder name and does **not** affect where things go in the output folder. 104 | 105 | pathPrefix: "/PyTado", 106 | }; 107 | -------------------------------------------------------------------------------- /.github/workflows/codeql-advanced.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL Advanced" 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | schedule: 9 | - cron: '16 13 * * 5' 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze (${{ matrix.language }}) 14 | # Runner size impacts CodeQL analysis time. To learn more, please see: 15 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 16 | # - https://gh.io/supported-runners-and-hardware-resources 17 | # - https://gh.io/using-larger-runners (GitHub.com only) 18 | # Consider using larger runners or machines with greater resources for possible analysis time improvements. 19 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 20 | permissions: 21 | # required for all workflows 22 | security-events: write 23 | 24 | # required to fetch internal or private CodeQL packs 25 | packages: read 26 | 27 | # only required for workflows in private repositories 28 | actions: read 29 | contents: read 30 | 31 | strategy: 32 | fail-fast: false 33 | matrix: 34 | include: 35 | - language: python 36 | build-mode: none 37 | # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' 38 | # Use `c-cpp` to analyze code written in C, C++ or both 39 | # Use 'java-kotlin' to analyze code written in Java, Kotlin or both 40 | # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both 41 | # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, 42 | # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. 43 | # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how 44 | # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages 45 | steps: 46 | - name: Checkout repository 47 | uses: actions/checkout@v6 48 | 49 | # Initializes the CodeQL tools for scanning. 50 | - name: Initialize CodeQL 51 | uses: github/codeql-action/init@v4 52 | with: 53 | languages: ${{ matrix.language }} 54 | build-mode: ${{ matrix.build-mode }} 55 | # If you wish to specify custom queries, you can do so here or in a config file. 56 | # By default, queries listed here will override any specified in a config file. 57 | # Prefix the list here with "+" to use these queries and those in the config file. 58 | 59 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 60 | # queries: security-extended,security-and-quality 61 | 62 | # If the analyze step fails for one of the languages you are analyzing with 63 | # "We were unable to automatically build your code", modify the matrix above 64 | # to set the build mode to "manual" for that language. Then modify this step 65 | # to build your code. 66 | # ℹ️ Command-line programs to run using the OS shell. 67 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 68 | - if: matrix.build-mode == 'manual' 69 | shell: bash 70 | run: | 71 | echo 'If you are using a "manual" build mode for one or more of the' \ 72 | 'languages you are analyzing, replace this with the commands to build' \ 73 | 'your code, for example:' 74 | echo ' make bootstrap' 75 | echo ' make release' 76 | exit 1 77 | 78 | - name: Perform CodeQL Analysis 79 | uses: github/codeql-action/analyze@v4 80 | with: 81 | category: "/language:${{matrix.language}}" 82 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # Ruff cache 75 | .ruff_cache 76 | 77 | # Example dev 78 | /examples/example_dev.py 79 | 80 | # PyBuilder 81 | .pybuilder/ 82 | target/ 83 | 84 | # Jupyter Notebook 85 | .ipynb_checkpoints 86 | 87 | # IPython 88 | profile_default/ 89 | ipython_config.py 90 | 91 | # pyenv 92 | # For a library or package, you might want to ignore these files since the code is 93 | # intended to run in multiple environments; otherwise, check them in: 94 | # .python-version 95 | 96 | # pipenv 97 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 98 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 99 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 100 | # install all needed dependencies. 101 | #Pipfile.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/#use-with-ide 116 | .pdm.toml 117 | 118 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 119 | __pypackages__/ 120 | 121 | # Celery stuff 122 | celerybeat-schedule 123 | celerybeat.pid 124 | 125 | # SageMath parsed files 126 | *.sage.py 127 | 128 | # Environments 129 | .env 130 | .venv 131 | env/ 132 | venv/ 133 | ENV/ 134 | env.bak/ 135 | venv.bak/ 136 | .envrc.private 137 | !/.envrc 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | .DS_Store 171 | junit.xml 172 | token.json 173 | 174 | # 11ty 175 | _site/ 176 | 177 | # node/npm 178 | node_modules/ 179 | package-lock.json 180 | docs/content/api.md 181 | -------------------------------------------------------------------------------- /generate_api_docs.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import inspect 3 | import os 4 | 5 | OUTPUT_FILE = "docs/content/api.md" 6 | MODULES_TO_SCAN = [ 7 | "PyTado.interface.api", # Main API classes 8 | "PyTado.interface", # Central interface 9 | "PyTado.zone", # Zone management 10 | ] 11 | 12 | 13 | def format_signature(method): 14 | """Returns the method signature as a readable string.""" 15 | try: 16 | signature = inspect.signature(method) 17 | except ValueError: 18 | return "()" 19 | 20 | param_list = [] 21 | for param_name, param in signature.parameters.items(): 22 | param_type = f": {param.annotation}" if param.annotation != inspect.Parameter.empty else "" 23 | param_list.append(f"**{param_name}**{param_type}") 24 | 25 | return f"({', '.join(param_list)})" 26 | 27 | 28 | def get_method_doc(method): 29 | """Generates formatted Markdown documentation for a method with parameter details.""" 30 | doc = f"### {method.__name__}{format_signature(method)}\n\n" 31 | docstring = inspect.getdoc(method) or "No description available." 32 | doc += f"{docstring}\n\n" 33 | 34 | try: 35 | signature = inspect.signature(method) 36 | params = signature.parameters 37 | if params: 38 | doc += "**Parameters:**\n\n" 39 | for name, param in params.items(): 40 | param_type = f"`{param.annotation}`" if param.annotation != inspect.Parameter.empty else "Unknown" # noqa: E501 41 | default_value = param.default if param.default != inspect.Parameter.empty else "Required" # noqa: E501 42 | doc += f"- **{name}** ({param_type}): {default_value}\n" 43 | doc += "\n" 44 | except ValueError: 45 | pass # If no signature can be extracted 46 | 47 | return doc 48 | 49 | 50 | def get_class_doc(cls): 51 | """ 52 | Generates formatted Markdown documentation for a class 53 | with methods and attributes. 54 | """ 55 | doc = f"## {cls.__name__}\n\n" 56 | doc += f"{inspect.getdoc(cls) or 'No documentation available.'}\n\n" 57 | 58 | # Collect attributes (no methods or private elements) 59 | attributes = {name: value for name, value in vars( 60 | cls).items() if not callable(value) and not name.startswith("_")} 61 | 62 | if attributes: 63 | doc += "**Attributes:**\n\n" 64 | for attr_name, attr_value in attributes.items(): 65 | attr_type = type(attr_value).__name__ 66 | doc += f"- **{attr_name}** (`{attr_type}`): `{attr_value}`\n" 67 | doc += "\n" 68 | 69 | # Document public methods 70 | methods = inspect.getmembers(cls, predicate=inspect.isfunction) 71 | for _, method in methods: 72 | if method.__name__.startswith("_"): # Skip private methods 73 | continue 74 | doc += get_method_doc(method) 75 | 76 | return doc 77 | 78 | 79 | def add_frontmatter(content, key="API", order=3): 80 | """Adds frontmatter for 11ty navigation.""" 81 | frontmatter = f"""--- 82 | eleventyNavigation: 83 | key: "{key}" 84 | order: {order} 85 | --- 86 | 87 | """ 88 | return frontmatter + content 89 | 90 | 91 | def generate_markdown(): 92 | """Generates a Markdown file with all relevant classes.""" 93 | os.makedirs(os.path.dirname(OUTPUT_FILE), exist_ok=True) 94 | doc = "# API Documentation for `PyTado`\n\n" 95 | 96 | for module_name in MODULES_TO_SCAN: 97 | try: 98 | module = importlib.import_module(module_name) 99 | classes = [ 100 | cls for _, 101 | cls in inspect.getmembers( 102 | module, 103 | predicate=inspect.isclass) if cls.__module__.startswith(module_name)] 104 | 105 | if classes: 106 | doc += f"## Module `{module_name}`\n\n" 107 | for cls in classes: 108 | doc += get_class_doc(cls) 109 | except Exception as e: 110 | print(f"Error loading {module_name}: {e}") 111 | 112 | with open(OUTPUT_FILE, "w", encoding="utf-8") as f: 113 | f.write(add_frontmatter(doc)) 114 | 115 | print(f"API documentation generated: {OUTPUT_FILE}") 116 | 117 | 118 | if __name__ == "__main__": 119 | generate_markdown() 120 | -------------------------------------------------------------------------------- /PyTado/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Module for querying and controlling Tado smart thermostats.""" 4 | 5 | import argparse 6 | import logging 7 | import sys 8 | 9 | from PyTado.interface import Tado 10 | 11 | 12 | def log_in(args: argparse.Namespace) -> Tado: 13 | """ 14 | Log in to the Tado API by activating the current device. 15 | 16 | Add --token_file_path to the command line arguments to store the 17 | refresh token in a file. 18 | 19 | Args: 20 | args (argparse.Namespace): The parsed command-line arguments. 21 | 22 | Returns: 23 | Tado: An instance of the Tado interface. 24 | """ 25 | t = Tado(token_file_path=args.token_file_path) 26 | t.device_activation() 27 | return t 28 | 29 | 30 | def get_me(args: argparse.Namespace) -> None: 31 | """ 32 | Retrieve and print home information from the Tado API. 33 | 34 | Args: 35 | args (argparse.Namespace): The parsed command-line arguments. 36 | """ 37 | t = log_in(args) 38 | me = t.get_me() 39 | print(me) 40 | 41 | 42 | def get_state(args: argparse.Namespace) -> None: 43 | """ 44 | Retrieve and print the state of a specific zone from the Tado API. 45 | 46 | Args: 47 | args (argparse.Namespace): The parsed command-line arguments. 48 | """ 49 | t = log_in(args) 50 | zone = t.get_state(int(args.zone)) 51 | print(zone) 52 | 53 | 54 | def get_states(args: argparse.Namespace) -> None: 55 | """ 56 | Retrieve and print the states of all zones from the Tado API. 57 | 58 | Args: 59 | args (argparse.Namespace): The parsed command-line arguments. 60 | """ 61 | t = log_in(args) 62 | zones = t.get_zone_states() 63 | print(zones) 64 | 65 | 66 | def get_capabilities(args: argparse.Namespace) -> None: 67 | """ 68 | Retrieve and print the capabilities of a specific zone from the Tado API. 69 | 70 | Args: 71 | args (argparse.Namespace): The parsed command-line arguments. 72 | """ 73 | t = log_in(args) 74 | capabilities = t.get_capabilities(int(args.zone)) 75 | print(capabilities) 76 | 77 | 78 | def main() -> None: 79 | """ 80 | Main method for the script. 81 | 82 | Sets up the argument parser, handles subcommands, and executes the appropriate function. 83 | """ 84 | parser = argparse.ArgumentParser( 85 | description="Pytado - Tado thermostat device control", 86 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 87 | ) 88 | 89 | required_flags = parser.add_argument_group("required arguments") 90 | 91 | # Required flags go here. 92 | required_flags.add_argument( 93 | "--token_file_path", 94 | required=True, 95 | help="Path to the file where the refresh token should be stored.", 96 | ) 97 | 98 | # Flags with default values go here. 99 | log_levels = {logging.getLevelName(level): level for level in [10, 20, 30, 40, 50]} 100 | parser.add_argument( 101 | "--loglevel", 102 | default="INFO", 103 | choices=list(log_levels.keys()), 104 | help="Logging level to print to the console.", 105 | ) 106 | 107 | subparsers = parser.add_subparsers() 108 | 109 | show_config_parser = subparsers.add_parser("get_me", help="Get home information.") 110 | show_config_parser.set_defaults(func=get_me) 111 | 112 | start_activity_parser = subparsers.add_parser( 113 | "get_state", help="Get state of zone." 114 | ) 115 | start_activity_parser.add_argument("--zone", help="Zone to get the state of.") 116 | start_activity_parser.set_defaults(func=get_state) 117 | 118 | start_activity_parser = subparsers.add_parser( 119 | "get_states", help="Get states of all zones." 120 | ) 121 | start_activity_parser.set_defaults(func=get_states) 122 | 123 | start_activity_parser = subparsers.add_parser( 124 | "get_capabilities", help="Get capabilities of zone." 125 | ) 126 | start_activity_parser.add_argument( 127 | "--zone", help="Zone to get the capabilities of." 128 | ) 129 | start_activity_parser.set_defaults(func=get_capabilities) 130 | 131 | args = parser.parse_args() 132 | 133 | logging.basicConfig( 134 | level=log_levels[args.loglevel], 135 | format="%(levelname)s:\t%(name)s\t%(message)s", 136 | ) 137 | 138 | sys.exit(args.func(args)) 139 | 140 | 141 | if __name__ == "__main__": 142 | main() 143 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest import mock 3 | 4 | import pytest 5 | from unittest import mock 6 | from PyTado.__main__ import ( 7 | main, 8 | log_in, 9 | get_me, 10 | get_state, 11 | get_states, 12 | get_capabilities, 13 | ) 14 | 15 | from PyTado.__main__ import ( 16 | get_capabilities, 17 | get_me, 18 | get_state, 19 | get_states, 20 | log_in, 21 | main, 22 | ) 23 | 24 | 25 | class TestMain(unittest.TestCase): 26 | """Test cases for the methods in __main__.py.""" 27 | 28 | def test_entry_point_no_args(self): 29 | """Test the main() method.""" 30 | with pytest.raises(SystemExit) as excinfo: 31 | main() 32 | 33 | assert excinfo.value.code == 2 34 | 35 | @mock.patch("PyTado.__main__.Tado") 36 | def test_log_in(self, mock_tado): 37 | """Test the log_in method.""" 38 | args = mock.Mock() 39 | args.token_file_path = "mock_token_file_path" 40 | 41 | # Call the method 42 | tado_instance = log_in(args) 43 | 44 | # Verify that Tado was initialized and device_activation was called 45 | mock_tado.assert_called_once_with(token_file_path="mock_token_file_path") 46 | mock_tado.return_value.device_activation.assert_called_once() 47 | self.assertEqual(tado_instance, mock_tado.return_value) 48 | 49 | @mock.patch("PyTado.__main__.log_in") 50 | def test_get_me(self, mock_log_in): 51 | """Test the get_me method.""" 52 | args = mock.Mock() 53 | mock_tado_instance = mock.Mock() 54 | mock_log_in.return_value = mock_tado_instance 55 | mock_tado_instance.get_me.return_value = {"mock": "data"} 56 | 57 | # Call the method 58 | with mock.patch("builtins.print") as mock_print: 59 | get_me(args) 60 | 61 | # Verify that log_in was called and get_me was called 62 | mock_log_in.assert_called_once_with(args) 63 | mock_tado_instance.get_me.assert_called_once() 64 | 65 | # Verify that the output was printed 66 | mock_print.assert_called_once_with({"mock": "data"}) 67 | 68 | @mock.patch("PyTado.__main__.log_in") 69 | def test_get_state(self, mock_log_in): 70 | """Test the get_state method.""" 71 | args = mock.Mock() 72 | args.zone = "1" 73 | mock_tado_instance = mock.Mock() 74 | mock_log_in.return_value = mock_tado_instance 75 | mock_tado_instance.get_state.return_value = {"zone": "state"} 76 | 77 | # Call the method 78 | with mock.patch("builtins.print") as mock_print: 79 | get_state(args) 80 | 81 | # Verify that log_in was called and get_state was called 82 | mock_log_in.assert_called_once_with(args) 83 | mock_tado_instance.get_state.assert_called_once_with(1) 84 | 85 | # Verify that the output was printed 86 | mock_print.assert_called_once_with({"zone": "state"}) 87 | 88 | @mock.patch("PyTado.__main__.log_in") 89 | def test_get_states(self, mock_log_in): 90 | """Test the get_states method.""" 91 | args = mock.Mock() 92 | mock_tado_instance = mock.Mock() 93 | mock_log_in.return_value = mock_tado_instance 94 | mock_tado_instance.get_zone_states.return_value = [ 95 | {"zone": "state1"}, 96 | {"zone": "state2"}, 97 | ] 98 | 99 | # Call the method 100 | with mock.patch("builtins.print") as mock_print: 101 | get_states(args) 102 | 103 | # Verify that log_in was called and get_zone_states was called 104 | mock_log_in.assert_called_once_with(args) 105 | mock_tado_instance.get_zone_states.assert_called_once() 106 | 107 | # Verify that the output was printed 108 | mock_print.assert_called_once_with([{"zone": "state1"}, {"zone": "state2"}]) 109 | 110 | @mock.patch("PyTado.__main__.log_in") 111 | def test_get_capabilities(self, mock_log_in): 112 | """Test the get_capabilities method.""" 113 | args = mock.Mock() 114 | args.zone = "1" 115 | mock_tado_instance = mock.Mock() 116 | mock_log_in.return_value = mock_tado_instance 117 | mock_tado_instance.get_capabilities.return_value = {"capabilities": "data"} 118 | 119 | # Call the method 120 | with mock.patch("builtins.print") as mock_print: 121 | get_capabilities(args) 122 | 123 | # Verify that log_in was called and get_capabilities was called 124 | mock_log_in.assert_called_once_with(args) 125 | mock_tado_instance.get_capabilities.assert_called_once_with(1) 126 | 127 | # Verify that the output was printed 128 | mock_print.assert_called_once_with({"capabilities": "data"}) 129 | -------------------------------------------------------------------------------- /tests/fixtures/home_1234/tadox.schedule.json: -------------------------------------------------------------------------------- 1 | { 2 | "room": { "id": 1, "name": "Room 1" }, 3 | "otherRooms": [{ "id": 2, "name": "Room 2" }], 4 | "schedule": [ 5 | { 6 | "dayType": "WEDNESDAY", 7 | "start": "00:00", 8 | "end": "05:00", 9 | "setting": { "power": "ON", "temperature": { "value": 18.0 } } 10 | }, 11 | { 12 | "dayType": "WEDNESDAY", 13 | "start": "05:00", 14 | "end": "07:00", 15 | "setting": { "power": "ON", "temperature": { "value": 18.0 } } 16 | }, 17 | { 18 | "dayType": "WEDNESDAY", 19 | "start": "07:00", 20 | "end": "22:00", 21 | "setting": { "power": "ON", "temperature": { "value": 21.0 } } 22 | }, 23 | { 24 | "dayType": "WEDNESDAY", 25 | "start": "22:00", 26 | "end": "24:00", 27 | "setting": { "power": "ON", "temperature": { "value": 18.0 } } 28 | }, 29 | { 30 | "dayType": "MONDAY", 31 | "start": "00:00", 32 | "end": "05:00", 33 | "setting": { "power": "ON", "temperature": { "value": 18.0 } } 34 | }, 35 | { 36 | "dayType": "MONDAY", 37 | "start": "05:00", 38 | "end": "07:00", 39 | "setting": { "power": "ON", "temperature": { "value": 18.0 } } 40 | }, 41 | { 42 | "dayType": "MONDAY", 43 | "start": "07:00", 44 | "end": "22:00", 45 | "setting": { "power": "ON", "temperature": { "value": 21.0 } } 46 | }, 47 | { 48 | "dayType": "MONDAY", 49 | "start": "22:00", 50 | "end": "24:00", 51 | "setting": { "power": "ON", "temperature": { "value": 18.0 } } 52 | }, 53 | { 54 | "dayType": "THURSDAY", 55 | "start": "00:00", 56 | "end": "05:00", 57 | "setting": { "power": "ON", "temperature": { "value": 18.0 } } 58 | }, 59 | { 60 | "dayType": "THURSDAY", 61 | "start": "05:00", 62 | "end": "07:00", 63 | "setting": { "power": "ON", "temperature": { "value": 18.0 } } 64 | }, 65 | { 66 | "dayType": "THURSDAY", 67 | "start": "07:00", 68 | "end": "22:00", 69 | "setting": { "power": "ON", "temperature": { "value": 21.0 } } 70 | }, 71 | { 72 | "dayType": "THURSDAY", 73 | "start": "22:00", 74 | "end": "24:00", 75 | "setting": { "power": "ON", "temperature": { "value": 18.0 } } 76 | }, 77 | { 78 | "dayType": "SUNDAY", 79 | "start": "00:00", 80 | "end": "05:00", 81 | "setting": { "power": "ON", "temperature": { "value": 18.0 } } 82 | }, 83 | { 84 | "dayType": "SUNDAY", 85 | "start": "05:00", 86 | "end": "07:00", 87 | "setting": { "power": "ON", "temperature": { "value": 18.0 } } 88 | }, 89 | { 90 | "dayType": "SUNDAY", 91 | "start": "07:00", 92 | "end": "22:00", 93 | "setting": { "power": "ON", "temperature": { "value": 21.0 } } 94 | }, 95 | { 96 | "dayType": "SUNDAY", 97 | "start": "22:00", 98 | "end": "24:00", 99 | "setting": { "power": "ON", "temperature": { "value": 18.0 } } 100 | }, 101 | { 102 | "dayType": "TUESDAY", 103 | "start": "00:00", 104 | "end": "05:00", 105 | "setting": { "power": "ON", "temperature": { "value": 18.0 } } 106 | }, 107 | { 108 | "dayType": "TUESDAY", 109 | "start": "05:00", 110 | "end": "07:00", 111 | "setting": { "power": "ON", "temperature": { "value": 18.0 } } 112 | }, 113 | { 114 | "dayType": "TUESDAY", 115 | "start": "07:00", 116 | "end": "22:00", 117 | "setting": { "power": "ON", "temperature": { "value": 21.0 } } 118 | }, 119 | { 120 | "dayType": "TUESDAY", 121 | "start": "22:00", 122 | "end": "24:00", 123 | "setting": { "power": "ON", "temperature": { "value": 18.0 } } 124 | }, 125 | { 126 | "dayType": "FRIDAY", 127 | "start": "00:00", 128 | "end": "05:00", 129 | "setting": { "power": "ON", "temperature": { "value": 18.0 } } 130 | }, 131 | { 132 | "dayType": "FRIDAY", 133 | "start": "05:00", 134 | "end": "07:00", 135 | "setting": { "power": "ON", "temperature": { "value": 18.0 } } 136 | }, 137 | { 138 | "dayType": "FRIDAY", 139 | "start": "07:00", 140 | "end": "22:00", 141 | "setting": { "power": "ON", "temperature": { "value": 21.0 } } 142 | }, 143 | { 144 | "dayType": "FRIDAY", 145 | "start": "22:00", 146 | "end": "24:00", 147 | "setting": { "power": "ON", "temperature": { "value": 18.0 } } 148 | }, 149 | { 150 | "dayType": "SATURDAY", 151 | "start": "00:00", 152 | "end": "05:00", 153 | "setting": { "power": "ON", "temperature": { "value": 18.0 } } 154 | }, 155 | { 156 | "dayType": "SATURDAY", 157 | "start": "05:00", 158 | "end": "07:00", 159 | "setting": { "power": "ON", "temperature": { "value": 18.0 } } 160 | }, 161 | { 162 | "dayType": "SATURDAY", 163 | "start": "07:00", 164 | "end": "22:00", 165 | "setting": { "power": "ON", "temperature": { "value": 21.0 } } 166 | }, 167 | { 168 | "dayType": "SATURDAY", 169 | "start": "22:00", 170 | "end": "24:00", 171 | "setting": { "power": "ON", "temperature": { "value": 18.0 } } 172 | } 173 | ] 174 | } 175 | -------------------------------------------------------------------------------- /PyTado/models/home.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime 2 | from typing import Any, Dict 3 | 4 | from pydantic import model_validator 5 | 6 | from PyTado.models.util import Base 7 | from PyTado.types import BatteryState, Presence 8 | 9 | 10 | class User(Base): 11 | """User model represents a user's profile information.""" 12 | 13 | name: str 14 | email: str 15 | id: str 16 | username: str 17 | locale: str 18 | homes: list["Home"] 19 | mobile_devices: list["MobileDevice"] 20 | 21 | 22 | class Home(Base): 23 | """Home model represents the user's home information.""" 24 | 25 | id: int 26 | name: str 27 | temperature_unit: str | None = None 28 | generation: str | None = None 29 | 30 | 31 | class TempPrecision(Base): 32 | celsius: float 33 | fahrenheit: float 34 | 35 | 36 | class Temperature(Base): 37 | """Temperature model represents the temperature in Celsius and Fahrenheit.""" 38 | 39 | celsius: float 40 | fahrenheit: float | None = None 41 | type: str | None = None 42 | timestamp: str | None = None 43 | precision: TempPrecision | None = None 44 | 45 | @model_validator(mode="before") 46 | @classmethod 47 | def __pre_deserialize__(cls, d: Dict[Any, Any]) -> Dict[Any, Any]: 48 | if d.get("value", None) is not None: 49 | d["celsius"] = d["value"] 50 | d["fahrenheit"] = float(d["value"]) * 9 / 5 + 32 51 | # TODO: get temperature unit from tado home info and convert accordingly 52 | return d 53 | 54 | 55 | class SolarIntensity(Base): 56 | """SolarIntensity model represents the solar intensity.""" 57 | 58 | percentage: float 59 | timestamp: str 60 | type: str 61 | 62 | 63 | class WeatherState(Base): 64 | """WeatherState model represents the weather state.""" 65 | 66 | timestamp: str 67 | type: str 68 | value: str 69 | 70 | 71 | class Weather(Base): 72 | """Weather model represents the weather information.""" 73 | 74 | outside_temperature: Temperature 75 | solar_intensity: SolarIntensity 76 | weather_state: WeatherState 77 | 78 | 79 | class HomeState(Base): 80 | """HomeState model represents the state of a home.""" 81 | 82 | presence: Presence 83 | presence_locked: bool | None 84 | show_home_presence_switch_button: bool | None = None 85 | show_switch_to_auto_geofencing_button: bool | None = None 86 | 87 | @property 88 | def presence_setting(self) -> Presence: 89 | if not self.presence_locked: 90 | return Presence.AUTO 91 | return self.presence 92 | 93 | 94 | class DeviceMetadata(Base): 95 | """DeviceMetadata model represents the metadata of a device.""" 96 | 97 | platform: str 98 | os_version: str 99 | model: str 100 | locale: str 101 | 102 | 103 | class MobileSettings(Base): 104 | """MobileSettings model represents the user's mobile device settings.""" 105 | 106 | geo_tracking_enabled: bool 107 | special_offers_enabled: bool 108 | on_demand_log_retrieval_enabled: bool 109 | push_notifications: dict[str, bool] 110 | 111 | 112 | class MobileBearingFromHome(Base): 113 | """MobileBearingFromHome model represents the bearing from home.""" 114 | 115 | degrees: float 116 | radians: float 117 | 118 | 119 | class MobileLocation(Base): 120 | """MobileLocation model represents the user's mobile device location.""" 121 | 122 | stale: bool 123 | at_home: bool 124 | bearing_from_home: MobileBearingFromHome 125 | relative_distance_from_home_fence: float 126 | 127 | 128 | class MobileDevice(Base): 129 | """MobileDevice model represents the user's mobile device information.""" 130 | 131 | name: str 132 | id: int 133 | device_metadata: DeviceMetadata 134 | settings: MobileSettings 135 | location: MobileLocation | None = None 136 | 137 | 138 | class Freshness(Base): 139 | value: str # TODO: use Enum or similar 140 | last_open_window: datetime 141 | 142 | 143 | class RoomComfortCoordinate(Base): 144 | radial: float 145 | angular: int 146 | 147 | 148 | class RoomComfort(Base): 149 | room_id: int 150 | temperature_level: str | None = None # TODO: use Enum or similar 151 | humidity_level: str | None = None # TODO: use Enum or similar 152 | coordinate: RoomComfortCoordinate | None = None 153 | 154 | 155 | class AirComfort(Base): 156 | freshness: Freshness 157 | comfort: list[RoomComfort] 158 | 159 | 160 | class EIQTariff(Base): 161 | unit: str 162 | last_updated: datetime 163 | end_date: date 164 | home_id: int 165 | start_date: date | None = None 166 | tariff_in_cents: int 167 | id: str 168 | 169 | 170 | class EIQMeterReading(Base): 171 | id: str 172 | home_id: int 173 | reading: int 174 | date: date 175 | 176 | 177 | class RunningTimeZone(Base): 178 | id: int 179 | running_time_in_seconds: int 180 | 181 | 182 | class RunningTime(Base): 183 | start_time: datetime 184 | end_time: datetime 185 | running_time_in_seconds: int 186 | zones: list[RunningTimeZone] 187 | 188 | 189 | class RunningTimeSummary(Base): 190 | start_time: datetime 191 | end_time: datetime 192 | total_running_time_in_seconds: int 193 | mean_in_seconds_per_day: int 194 | 195 | 196 | class RunningTimes(Base): 197 | running_times: list[RunningTime] 198 | summary: RunningTimeSummary 199 | last_updated: datetime 200 | 201 | 202 | class ActionableDevice: 203 | serial_number: str 204 | needs_mounting: bool 205 | isOffline: bool 206 | batteryState: BatteryState 207 | -------------------------------------------------------------------------------- /tests/test_tado_initializer.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the Tado Client Initializer module. 2 | 3 | This module contains test cases for verifying the initialization and configuration 4 | of Tado clients, focusing on device activation, API version detection, and client 5 | factory functionality. 6 | 7 | The tests use mocking to simulate different device types and API responses, 8 | ensuring proper client initialization for both standard and X-line Tado devices. 9 | """ 10 | 11 | import unittest 12 | from unittest import mock 13 | 14 | from PyTado.http import DeviceActivationStatus 15 | from PyTado.factory import TadoClientInitializer, Tado 16 | 17 | 18 | class TestTadoClientInitializer(unittest.TestCase): 19 | """Test suite for the TadoClientInitializer class. 20 | 21 | This test suite verifies the functionality of the TadoClientInitializer class, 22 | which is responsible for: 23 | - Creating appropriate Tado client instances 24 | - Managing device activation flow 25 | - Determining device type (standard vs X-line) 26 | - Configuring the correct API version for the device 27 | 28 | The suite uses extensive mocking to test different device scenarios and 29 | API responses without requiring actual device connections. 30 | """ 31 | 32 | def setUp(self): 33 | """Set up test fixtures for all test methods. 34 | 35 | Configures mock objects for: 36 | - Device flow login process 37 | - Device activation checking 38 | - Device ID retrieval 39 | 40 | All mocks are automatically cleaned up after each test. 41 | """ 42 | super().setUp() 43 | 44 | login_patch = mock.patch( 45 | "PyTado.http.Http._login_device_flow", 46 | return_value=DeviceActivationStatus.PENDING, 47 | ) 48 | login_patch.start() 49 | 50 | device_activation_patch = mock.patch( 51 | "PyTado.http.Http._check_device_activation", return_value=True 52 | ) 53 | device_activation_patch.start() 54 | 55 | get_id_patch = mock.patch("PyTado.http.Http._get_id") 56 | get_id_patch.start() 57 | 58 | self.addCleanup(login_patch.stop) 59 | self.addCleanup(device_activation_patch.stop) 60 | self.addCleanup(get_id_patch.stop) 61 | 62 | @mock.patch("PyTado.interface.api.my_tado.Tado.get_me") 63 | @mock.patch("PyTado.interface.api.hops_tado.TadoX.get_me") 64 | def test_interface_with_tado_api( 65 | self, mock_hops_get_me: mock.MagicMock, mock_my_get_me: mock.MagicMock 66 | ): 67 | """Test client initialization for standard Tado devices. 68 | 69 | Verifies that when a standard (non-X-line) Tado device is detected: 70 | - The correct API client type is instantiated 71 | - Standard API endpoints are used 72 | - X-line API endpoints are not called 73 | 74 | Args: 75 | mock_hops_get_me: Mock for the HopsTado get_me method 76 | mock_my_get_me: Mock for the MyTado get_me method 77 | """ 78 | check_x_patch = mock.patch( 79 | "PyTado.http.Http._check_x_line_generation", return_value=False 80 | ) 81 | check_x_patch.start() 82 | self.addCleanup(check_x_patch.stop) 83 | 84 | tado_interface = TadoClientInitializer() 85 | tado_interface.device_activation() 86 | 87 | client = tado_interface.get_client() 88 | client.get_me() 89 | 90 | assert not tado_interface.http.is_x_line 91 | 92 | mock_my_get_me.assert_called_once() 93 | mock_hops_get_me.assert_not_called() 94 | 95 | @mock.patch("PyTado.interface.api.my_tado.Tado.get_me") 96 | @mock.patch("PyTado.interface.api.hops_tado.TadoX.get_me") 97 | def test_interface_with_tadox_api( 98 | self, mock_hops_get_me: mock.MagicMock, mock_my_get_me: mock.MagicMock 99 | ): 100 | """Test client initialization for Tado X-line devices. 101 | 102 | Verifies that when an X-line compatible Tado device is detected: 103 | - The correct API client type is instantiated 104 | - X-line API endpoints are used 105 | - Standard API endpoints are not called 106 | 107 | Args: 108 | mock_hops_get_me: Mock for the HopsTado get_me method 109 | mock_my_get_me: Mock for the MyTado get_me method 110 | """ 111 | 112 | with mock.patch("PyTado.http.Http._check_x_line_generation") as check_x_patch: 113 | check_x_patch.return_value = True 114 | 115 | tado_interface = TadoClientInitializer() 116 | tado_interface.device_activation() 117 | 118 | client = tado_interface.get_client() 119 | client.get_me() 120 | 121 | assert tado_interface.http.is_x_line 122 | 123 | mock_my_get_me.assert_not_called() 124 | mock_hops_get_me.assert_called_once() 125 | 126 | def test_error_handling_on_api_calls(self): 127 | """Test error handling during API operations. 128 | 129 | Verifies that when API calls fail: 130 | - Exceptions are properly propagated 131 | - Error messages are preserved 132 | - The client doesn't silently handle critical errors 133 | 134 | Uses a mock to simulate API errors and validates the error 135 | handling behavior of the client. 136 | """ 137 | with mock.patch("PyTado.interface.api.my_tado.Tado.get_me") as mock_it: 138 | mock_it.side_effect = Exception("API Error") 139 | 140 | tado_interface = Tado() 141 | 142 | with self.assertRaises(Exception) as context: 143 | tado_interface.get_me() 144 | 145 | self.assertIn("API Error", str(context.exception)) 146 | -------------------------------------------------------------------------------- /PyTado/types.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains type definitions and enumerations for the PyTado library. 3 | 4 | As opposed to const.py, this module is intended to define types that are used 5 | throughout the library, such as Enums for various states and modes. It provides 6 | a centralized location for type-related definitions, enhancing code organization 7 | and maintainability. 8 | """ 9 | 10 | from enum import IntEnum, StrEnum 11 | from typing import Any 12 | 13 | from PyTado.logger import Logger 14 | 15 | logger = Logger(__name__) 16 | 17 | 18 | class StrEnumMissing(StrEnum): 19 | """ 20 | A custom string-based Enum class that provides enhanced handling for missing enum values. 21 | 22 | When an unknown value is encountered, the `_missing_` class method is invoked, which: 23 | - Logs a debug message indicating the missing key. 24 | - Dynamically creates a new enum member with the missing value, allowing the program 25 | to continue without raising an exception. 26 | 27 | The `__str__` method is overridden to return the enum member's name as its string 28 | representation. 29 | 30 | This class is useful for debugging and gracefully handling unexpected enum values, but 31 | the `_missing_` method can be removed if not needed. 32 | """ 33 | 34 | def __str__(self) -> str: 35 | return self.name 36 | 37 | @classmethod 38 | def _missing_(cls, value: Any) -> Any: # pragma: no cover 39 | """Debug missing enum values and return a missing value. 40 | (This is just for debugging, can be removed if not needed anymore) 41 | """ 42 | logger.debug("enum %s is missing key %r", cls, value) 43 | unknown_enum_val = str.__new__(cls) 44 | unknown_enum_val._name_ = str(value) 45 | unknown_enum_val._value_ = value 46 | return unknown_enum_val 47 | 48 | 49 | class Presence(StrEnumMissing): 50 | """Presence Enum""" 51 | 52 | HOME = "HOME" 53 | AWAY = "AWAY" 54 | TADO_MODE = "TADO_MODE" 55 | AUTO = "AUTO" 56 | 57 | 58 | class Power(StrEnumMissing): 59 | """Power Enum""" 60 | 61 | ON = "ON" 62 | OFF = "OFF" 63 | 64 | 65 | class Timetable(IntEnum): 66 | """Timetable Enum""" 67 | 68 | ONE_DAY = 0 69 | THREE_DAY = 1 70 | SEVEN_DAY = 2 71 | 72 | 73 | class ZoneType(StrEnumMissing): 74 | """Zone Type Enum""" 75 | 76 | HEATING = "HEATING" 77 | HOT_WATER = "HOT_WATER" 78 | AIR_CONDITIONING = "AIR_CONDITIONING" 79 | 80 | 81 | class HvacMode(StrEnumMissing): 82 | """ 83 | HVAC Mode Enum representing the different operating modes of a heating, 84 | ventilation, and air conditioning system. 85 | """ 86 | 87 | OFF = "OFF" 88 | SMART_SCHEDULE = "SMART_SCHEDULE" 89 | AUTO = "AUTO" 90 | COOL = "COOL" 91 | HEAT = "HEAT" 92 | DRY = "DRY" 93 | FAN = "FAN" 94 | 95 | 96 | class FanLevel(StrEnumMissing): 97 | """Fan Level Enum""" 98 | 99 | # In the app.tado.com source code there is a convertOldCapabilitiesToNew function 100 | # which uses FanLevel for new and FanSpeed for old. 101 | # This is why we have both enums here. 102 | SILENT = "SILENT" 103 | OFF = "OFF" 104 | LEVEL1 = "LEVEL1" 105 | LEVEL2 = "LEVEL2" 106 | LEVEL3 = "LEVEL3" 107 | LEVEL4 = "LEVEL4" 108 | LEVEL5 = "LEVEL5" 109 | AUTO = "AUTO" 110 | 111 | 112 | class FanSpeed(StrEnumMissing): 113 | """Enum representing the fan speed settings.""" 114 | 115 | OFF = "OFF" 116 | AUTO = "AUTO" 117 | LOW = "LOW" 118 | MIDDLE = "MIDDLE" 119 | HIGH = "HIGH" 120 | 121 | 122 | class VerticalSwing(StrEnumMissing): 123 | """Enum for controlling the vertical air flow direction.""" 124 | 125 | OFF = "OFF" 126 | ON = "ON" 127 | MID_UP = "MID_UP" 128 | MID = "MID" 129 | MID_DOWN = "MID_DOWN" 130 | DOWN = "DOWN" 131 | UP = "UP" 132 | 133 | 134 | class HorizontalSwing(StrEnumMissing): 135 | """Enum for controlling the horizontal air flow direction.""" 136 | 137 | OFF = "OFF" 138 | ON = "ON" 139 | LEFT = "LEFT" 140 | MID_LEFT = "MID_LEFT" 141 | MID = "MID" # TODO: Does this exists? 142 | MID_RIGHT = "MID_RIGHT" 143 | RIGHT = "RIGHT" 144 | 145 | 146 | class OverlayMode(StrEnumMissing): 147 | """Overlay Mode Enum for controlling schedule override behavior.""" 148 | 149 | TADO_MODE = "TADO_MODE" 150 | NEXT_TIME_BLOCK = "NEXT_TIME_BLOCK" 151 | MANUAL = "MANUAL" 152 | TIMER = "TIMER" 153 | 154 | 155 | class HvacAction(StrEnumMissing): 156 | """Enum representing the current operation being performed by the system.""" 157 | 158 | HEATING = "HEATING" 159 | DRYING = "DRYING" 160 | FAN = "FAN" 161 | COOLING = "COOLING" 162 | IDLE = "IDLE" 163 | OFF = "OFF" 164 | HOT_WATER = "HOT_WATER" 165 | 166 | 167 | class DayType(StrEnumMissing): 168 | """Enumeration representing different types of days or day ranges.""" 169 | 170 | MONDAY = "MONDAY" 171 | TUESDAY = "TUESDAY" 172 | WEDNESDAY = "WEDNESDAY" 173 | THURSDAY = "THURSDAY" 174 | FRIDAY = "FRIDAY" 175 | SATURDAY = "SATURDAY" 176 | SUNDAY = "SUNDAY" 177 | MONDAY_TO_FRIDAY = "MONDAY_TO_FRIDAY" 178 | MONDAY_TO_SUNDAY = "MONDAY_TO_SUNDAY" 179 | 180 | 181 | class LinkState(StrEnumMissing): 182 | """Link State Enum""" 183 | 184 | ONLINE = "ONLINE" 185 | OFFLINE = "OFFLINE" 186 | 187 | 188 | class ConnectionState(StrEnumMissing): 189 | """Connection State Enum""" 190 | 191 | CONNECTED = "CONNECTED" 192 | DISCONNECTED = "DISCONNECTED" 193 | 194 | 195 | class BatteryState(StrEnumMissing): 196 | """Battery State Enum""" 197 | 198 | LOW = "LOW" 199 | DEPLETED = "DEPLETED" 200 | NORMAL = "NORMAL" 201 | 202 | 203 | class Unit(StrEnumMissing): 204 | """Unit Enum""" 205 | 206 | M3 = "m3" 207 | KWH = "kWh" 208 | -------------------------------------------------------------------------------- /tests/test_tado_interface.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the Tado Interface module. 2 | 3 | This module contains test cases that verify the functionality of the main Tado 4 | interface class, which serves as the primary interaction point with Tado devices 5 | and their APIs. The tests cover: 6 | 7 | - API version detection and selection (standard vs X-line) 8 | - Device activation and authentication flow 9 | - Error handling and exception propagation 10 | - Token management and refresh mechanisms 11 | - Integration with both MyTado and HopsTado APIs 12 | 13 | The test suite uses mocking to simulate various device responses and API 14 | behaviors, allowing comprehensive testing without requiring physical Tado 15 | devices or network connectivity. 16 | """ 17 | 18 | import unittest 19 | from unittest import mock 20 | 21 | 22 | from PyTado.http import DeviceActivationStatus 23 | from PyTado.interface import Tado 24 | 25 | 26 | class TestTadoInterface(unittest.TestCase): 27 | """Test suite for the Tado interface class. 28 | 29 | This test suite verifies the functionality of the main Tado interface class, 30 | particularly focusing on: 31 | - Device activation flow 32 | - API version detection and selection 33 | - Integration with MyTado and HopsTado APIs 34 | - Authentication and session management 35 | 36 | Each test case uses mocking to isolate the interface from actual network 37 | calls and external dependencies. 38 | """ 39 | 40 | def setUp(self): 41 | super().setUp() 42 | 43 | login_patch = mock.patch( 44 | "PyTado.http.Http._login_device_flow", 45 | return_value=DeviceActivationStatus.PENDING, 46 | ) 47 | login_patch.start() 48 | 49 | device_activation_patch = mock.patch( 50 | "PyTado.http.Http._check_device_activation", return_value=True 51 | ) 52 | device_activation_patch.start() 53 | 54 | get_id_patch = mock.patch("PyTado.http.Http._get_id") 55 | get_id_patch.start() 56 | 57 | self.addCleanup(login_patch.stop) 58 | self.addCleanup(device_activation_patch.stop) 59 | self.addCleanup(get_id_patch.stop) 60 | 61 | @mock.patch("PyTado.interface.api.my_tado.Tado.get_me") 62 | @mock.patch("PyTado.interface.api.hops_tado.TadoX.get_me") 63 | def test_interface_with_tado_api( 64 | self, mock_hops_get_me: mock.MagicMock, mock_my_get_me: mock.MagicMock 65 | ): 66 | """Test the interface behavior when using the regular Tado API. 67 | 68 | This test verifies that when the device is not X-line compatible, 69 | the interface correctly: 70 | - Uses the standard Tado API 71 | - Calls the appropriate get_me method 72 | - Does not attempt to use the X-line API 73 | 74 | Args: 75 | mock_hops_get_me: Mock for the HopsTado get_me method 76 | mock_my_get_me: Mock for the MyTado get_me method 77 | """ 78 | check_x_patch = mock.patch( 79 | "PyTado.http.Http._check_x_line_generation", return_value=False 80 | ) 81 | check_x_patch.start() 82 | self.addCleanup(check_x_patch.stop) 83 | 84 | tado_interface = Tado() 85 | tado_interface.device_activation() 86 | tado_interface.get_me() 87 | 88 | assert not tado_interface._http.is_x_line # pyright: ignore[reportPrivateUsage] 89 | 90 | mock_my_get_me.assert_called_once() 91 | mock_hops_get_me.assert_not_called() 92 | 93 | @mock.patch("PyTado.interface.api.my_tado.Tado.get_me") 94 | @mock.patch("PyTado.interface.api.hops_tado.TadoX.get_me") 95 | def test_interface_with_tadox_api( 96 | self, mock_hops_get_me: mock.MagicMock, mock_my_get_me: mock.MagicMock 97 | ): 98 | """Test the interface behavior when using the TadoX API. 99 | 100 | This test verifies that when the device is X-line compatible, 101 | the interface correctly: 102 | - Uses the X-line API 103 | - Calls the appropriate get_me method 104 | - Does not attempt to use the standard Tado API 105 | 106 | Args: 107 | mock_hops_get_me: Mock for the HopsTado get_me method 108 | mock_my_get_me: Mock for the MyTado get_me method 109 | """ 110 | 111 | with mock.patch("PyTado.http.Http._check_x_line_generation") as check_x_patch: 112 | check_x_patch.return_value = True 113 | 114 | tado_interface = Tado() 115 | tado_interface.device_activation() 116 | tado_interface.get_me() 117 | 118 | assert tado_interface._http.is_x_line # pyright: ignore[reportPrivateUsage] 119 | 120 | mock_my_get_me.assert_not_called() 121 | mock_hops_get_me.assert_called_once() 122 | 123 | def test_error_handling_on_api_calls(self): 124 | """Test error handling behavior when API calls fail. 125 | 126 | Verifies that exceptions from the underlying API calls are properly 127 | propagated through the interface, ensuring errors are not silently 128 | caught and that error messages are preserved. 129 | """ 130 | with mock.patch("PyTado.interface.api.my_tado.Tado.get_me") as mock_it: 131 | mock_it.side_effect = Exception("API Error") 132 | 133 | tado_interface = Tado() 134 | 135 | with self.assertRaises(Exception) as context: 136 | tado_interface.get_me() 137 | 138 | self.assertIn("API Error", str(context.exception)) 139 | 140 | def test_get_refresh_token(self): 141 | """Test the retrieval of refresh tokens. 142 | 143 | Verifies that the interface correctly provides access to the 144 | refresh token from the underlying HTTP client, which is used 145 | for maintaining authentication sessions. 146 | """ 147 | tado = Tado() 148 | with mock.patch.object(tado._http, "_token_refresh", new="mock_refresh_token"): 149 | self.assertEqual(tado.get_refresh_token(), "mock_refresh_token") 150 | -------------------------------------------------------------------------------- /PyTado/interface/interface.py: -------------------------------------------------------------------------------- 1 | """ 2 | PyTado interface abstraction to use app.tado.com or hops.tado.com 3 | """ 4 | 5 | import functools 6 | import warnings 7 | from typing import Any, Callable, TypeVar, cast 8 | 9 | import requests 10 | 11 | import PyTado.interface.api as API 12 | from PyTado.exceptions import TadoException 13 | from PyTado.http import DeviceActivationStatus, Http 14 | 15 | F = TypeVar("F", bound=Callable[..., Any]) # Type variable for function 16 | 17 | 18 | def deprecated(new_func_name: str) -> Callable[[F], F]: 19 | """ 20 | A decorator to mark functions as deprecated. It will result in a warning being emitted 21 | when the function is used, advising the user to use the new function instead. 22 | 23 | Args: 24 | new_func_name (str): The name of the new function that should be used instead. 25 | 26 | Returns: 27 | Callable[[F], F]: A decorator that wraps the deprecated function and emits a warning. 28 | """ 29 | 30 | def decorator(func: F) -> F: 31 | @functools.wraps(func) 32 | def wrapper(*args: Any, **kwargs: Any) -> Any: 33 | warnings.warn( 34 | f"The '{func.__name__}' method is deprecated, use '{new_func_name}' instead. " 35 | "Deprecated methods will be removed with 1.0.0.", 36 | DeprecationWarning, 37 | stacklevel=2, 38 | ) 39 | return getattr(args[0], new_func_name)(*args[1:], **kwargs) 40 | 41 | return cast(F, wrapper) 42 | 43 | return decorator 44 | 45 | 46 | class Tado: 47 | """Interacts with a Tado thermostat via public API. 48 | 49 | Example usage: t = Tado() 50 | t.device_activation() # Activate device 51 | t.get_climate(1) # Get climate, zone 1. 52 | """ 53 | 54 | def __init__( 55 | self, 56 | token_file_path: str | None = None, 57 | saved_refresh_token: str | None = None, 58 | http_session: requests.Session | None = None, 59 | debug: bool = False, 60 | user_agent: str | None = None, 61 | ): 62 | """ 63 | Initializes the interface class. 64 | 65 | Args: 66 | token_file_path (str | None, optional): Path to a file which will be used to persist 67 | the refresh_token token. Defaults to None. 68 | saved_refresh_token (str | None, optional): A previously saved refresh token. 69 | Defaults to None. 70 | http_session (requests.Session | None, optional): An optional HTTP session to use for 71 | requests (can be used in unit tests). Defaults to None. 72 | debug (bool, optional): Flag to enable or disable debug mode. Defaults to False. 73 | user_agent (str | None): Optional user-agent header to use for the HTTP requests. 74 | If None, a default user-agent PyTado/ will be used. 75 | """ 76 | 77 | self._http = Http( 78 | token_file_path=token_file_path, 79 | saved_refresh_token=saved_refresh_token, 80 | http_session=http_session, 81 | debug=debug, 82 | user_agent=user_agent, 83 | ) 84 | self._api: API.Tado | API.TadoX | None = None 85 | self._debug = debug 86 | 87 | def __getattr__(self, name: str) -> Any: 88 | """ 89 | Delegate the called method to api implementation (hops_tado.py or my_tado.py). 90 | 91 | Args: 92 | name: The name of the attribute/method being accessed 93 | 94 | Returns: 95 | Any: The delegated attribute or method from the underlying API implementation 96 | 97 | Raises: 98 | TadoException: If the API is not initialized 99 | AttributeError: If the attribute doesn't exist on the API implementation 100 | """ 101 | self._ensure_api_initialized() 102 | return getattr(self._api, name) 103 | 104 | def device_verification_url(self) -> str | None: 105 | """ 106 | Returns the URL for device verification. 107 | 108 | Returns: 109 | str | None: The verification URL or None if not available 110 | """ 111 | return self._http.device_verification_url 112 | 113 | def device_activation_status(self) -> DeviceActivationStatus: 114 | """ 115 | Returns the status of the device activation. 116 | 117 | Returns: 118 | DeviceActivationStatus: The current activation status of the device 119 | """ 120 | return self._http.device_activation_status 121 | 122 | def device_activation(self) -> None: 123 | """ 124 | Activates the device and initializes the API client. 125 | 126 | Raises: 127 | TadoException: If device activation fails 128 | """ 129 | self._http.device_activation() 130 | self._ensure_api_initialized() 131 | 132 | def get_refresh_token(self) -> str | None: 133 | """ 134 | Retrieve the refresh token from the current api connection. 135 | 136 | Returns: 137 | str | None: The current refresh token, or None if not available. 138 | """ 139 | return self._http.refresh_token 140 | 141 | def _ensure_api_initialized(self) -> None: 142 | """ 143 | Ensures the API client is initialized. 144 | 145 | Raises: 146 | TadoException: If device authentication is not completed 147 | """ 148 | if self._api is None: 149 | if self._http.device_activation_status == DeviceActivationStatus.COMPLETED: 150 | if self._http.is_x_line: 151 | self._api = API.TadoX.from_http(http=self._http, debug=self._debug) 152 | else: 153 | self._api = API.Tado.from_http(http=self._http, debug=self._debug) 154 | else: 155 | raise TadoException( 156 | "API is not initialized. Please complete device authentication first." 157 | ) 158 | -------------------------------------------------------------------------------- /public/css/index.css: -------------------------------------------------------------------------------- 1 | /* Defaults */ 2 | :root { 3 | --font-family: -apple-system, system-ui, sans-serif; 4 | --font-family-monospace: Consolas, Menlo, Monaco, Andale Mono WT, Andale Mono, Lucida Console, Lucida Sans Typewriter, DejaVu Sans Mono, Bitstream Vera Sans Mono, Liberation Mono, Nimbus Mono L, Courier New, Courier, monospace; 5 | } 6 | 7 | /* Theme colors */ 8 | :root { 9 | --color-gray-20: #e0e0e0; 10 | --color-gray-50: #C0C0C0; 11 | --color-gray-90: #333; 12 | 13 | --background-color: #fff; 14 | 15 | --text-color: var(--color-gray-90); 16 | --text-color-link: #082840; 17 | --text-color-link-active: #5f2b48; 18 | --text-color-link-visited: #17050F; 19 | 20 | --syntax-tab-size: 2; 21 | } 22 | 23 | @media (prefers-color-scheme: dark) { 24 | :root { 25 | --color-gray-20: #e0e0e0; 26 | --color-gray-50: #C0C0C0; 27 | --color-gray-90: #dad8d8; 28 | 29 | /* --text-color is assigned to --color-gray-_ above */ 30 | --text-color-link: #1493fb; 31 | --text-color-link-active: #6969f7; 32 | --text-color-link-visited: #a6a6f8; 33 | 34 | --background-color: #15202b; 35 | } 36 | } 37 | 38 | 39 | /* Global stylesheet */ 40 | * { 41 | box-sizing: border-box; 42 | } 43 | 44 | @view-transition { 45 | navigation: auto; 46 | } 47 | 48 | html, 49 | body { 50 | padding: 0; 51 | margin: 0 auto; 52 | font-family: var(--font-family); 53 | color: var(--text-color); 54 | background-color: var(--background-color); 55 | } 56 | html { 57 | overflow-y: scroll; 58 | } 59 | body { 60 | max-width: 40em; 61 | } 62 | 63 | /* https://www.a11yproject.com/posts/how-to-hide-content/ */ 64 | .visually-hidden { 65 | clip: rect(0 0 0 0); 66 | clip-path: inset(50%); 67 | height: 1px; 68 | overflow: hidden; 69 | position: absolute; 70 | white-space: nowrap; 71 | width: 1px; 72 | } 73 | 74 | /* Fluid images via https://www.zachleat.com/web/fluid-images/ */ 75 | img{ 76 | max-width: 100%; 77 | } 78 | img[width][height] { 79 | height: auto; 80 | } 81 | img[src$=".svg"] { 82 | width: 100%; 83 | height: auto; 84 | max-width: none; 85 | } 86 | video, 87 | iframe { 88 | width: 100%; 89 | height: auto; 90 | } 91 | iframe { 92 | aspect-ratio: 16/9; 93 | } 94 | 95 | p:last-child { 96 | margin-bottom: 0; 97 | } 98 | p { 99 | line-height: 1.5; 100 | } 101 | 102 | li { 103 | line-height: 1.5; 104 | } 105 | 106 | a[href] { 107 | color: var(--text-color-link); 108 | } 109 | a[href]:visited { 110 | color: var(--text-color-link-visited); 111 | } 112 | a[href]:hover, 113 | a[href]:active { 114 | color: var(--text-color-link-active); 115 | } 116 | 117 | main, 118 | footer { 119 | padding: 1rem; 120 | } 121 | main :first-child { 122 | margin-top: 0; 123 | } 124 | 125 | header { 126 | border-bottom: 1px dashed var(--color-gray-20); 127 | } 128 | header:after { 129 | content: ""; 130 | display: table; 131 | clear: both; 132 | } 133 | 134 | .links-nextprev { 135 | display: flex; 136 | justify-content: space-between; 137 | gap: .5em 1em; 138 | list-style: ""; 139 | border-top: 1px dashed var(--color-gray-20); 140 | padding: 1em 0; 141 | } 142 | .links-nextprev > * { 143 | flex-grow: 1; 144 | } 145 | .links-nextprev-next { 146 | text-align: right; 147 | } 148 | 149 | table { 150 | margin: 1em 0; 151 | } 152 | table td, 153 | table th { 154 | padding-right: 1em; 155 | } 156 | 157 | pre, 158 | code { 159 | font-family: var(--font-family-monospace); 160 | } 161 | pre:not([class*="language-"]) { 162 | margin: .5em 0; 163 | line-height: 1.375; /* 22px /16 */ 164 | -moz-tab-size: var(--syntax-tab-size); 165 | -o-tab-size: var(--syntax-tab-size); 166 | tab-size: var(--syntax-tab-size); 167 | -webkit-hyphens: none; 168 | -ms-hyphens: none; 169 | hyphens: none; 170 | direction: ltr; 171 | text-align: left; 172 | white-space: pre; 173 | word-spacing: normal; 174 | word-break: normal; 175 | overflow-x: auto; 176 | } 177 | code { 178 | word-break: break-all; 179 | } 180 | 181 | /* Header */ 182 | header { 183 | display: flex; 184 | gap: 1em .5em; 185 | flex-wrap: wrap; 186 | align-items: center; 187 | padding: 1em; 188 | } 189 | .home-link { 190 | font-size: 1em; /* 16px /16 */ 191 | font-weight: 700; 192 | margin-right: 2em; 193 | } 194 | .home-link:link:not(:hover) { 195 | text-decoration: none; 196 | } 197 | 198 | /* Nav */ 199 | .nav { 200 | display: flex; 201 | padding: 0; 202 | margin: 0; 203 | list-style: none; 204 | } 205 | .nav-item { 206 | display: inline-block; 207 | margin-right: 1em; 208 | } 209 | .nav-item a[href]:not(:hover) { 210 | text-decoration: none; 211 | } 212 | .nav a[href][aria-current="page"] { 213 | text-decoration: underline; 214 | } 215 | 216 | /* Posts list */ 217 | .postlist { 218 | list-style: none; 219 | padding: 0; 220 | padding-left: 1.5rem; 221 | } 222 | .postlist-item { 223 | display: flex; 224 | flex-wrap: wrap; 225 | align-items: baseline; 226 | counter-increment: start-from -1; 227 | margin-bottom: 1em; 228 | } 229 | .postlist-item:before { 230 | display: inline-block; 231 | pointer-events: none; 232 | content: "" counter(start-from, decimal-leading-zero) ". "; 233 | line-height: 100%; 234 | text-align: right; 235 | margin-left: -1.5rem; 236 | } 237 | .postlist-date, 238 | .postlist-item:before { 239 | font-size: 0.8125em; /* 13px /16 */ 240 | color: var(--color-gray-90); 241 | } 242 | .postlist-date { 243 | word-spacing: -0.5px; 244 | } 245 | .postlist-link { 246 | font-size: 1.1875em; /* 19px /16 */ 247 | font-weight: 700; 248 | flex-basis: calc(100% - 1.5rem); 249 | padding-left: .25em; 250 | padding-right: .5em; 251 | text-underline-position: from-font; 252 | text-underline-offset: 0; 253 | text-decoration-thickness: 1px; 254 | } 255 | .postlist-item-active .postlist-link { 256 | font-weight: bold; 257 | } 258 | 259 | /* Tags */ 260 | .post-tag { 261 | display: inline-flex; 262 | align-items: center; 263 | justify-content: center; 264 | text-transform: capitalize; 265 | font-style: italic; 266 | } 267 | .postlist-item > .post-tag { 268 | align-self: center; 269 | } 270 | 271 | /* Tags list */ 272 | .post-metadata { 273 | display: inline-flex; 274 | flex-wrap: wrap; 275 | gap: .5em; 276 | list-style: none; 277 | padding: 0; 278 | margin: 0; 279 | } 280 | .post-metadata time { 281 | margin-right: 1em; 282 | } 283 | -------------------------------------------------------------------------------- /tests/test_my_tado.py: -------------------------------------------------------------------------------- 1 | """Test the interface.api.Tado object.""" 2 | 3 | import json 4 | from datetime import date, datetime 5 | from unittest import mock 6 | 7 | import responses 8 | 9 | from PyTado.http import TadoRequest 10 | 11 | from . import common 12 | 13 | 14 | class TadoTestCase(common.TadoBaseTestCase, is_x_line=False): 15 | """Test cases for tado class""" 16 | 17 | def setUp(self) -> None: 18 | super().setUp() 19 | 20 | responses.add( 21 | responses.DELETE, 22 | "https://my.tado.com/api/v2/homes/1234/presenceLock", 23 | status=204, 24 | ) 25 | 26 | responses.add( 27 | responses.DELETE, 28 | "https://my.tado.com/api/v2/homes/1234/presenceLock", 29 | status=204, 30 | ) 31 | 32 | @responses.activate 33 | def test_home_set_to_manual_mode(self): 34 | responses.add( 35 | responses.GET, 36 | "https://my.tado.com/api/v2/homes/1234/state", 37 | json=json.loads( 38 | common.load_fixture("tadov2.home_state.auto_supported.manual_mode.json") 39 | ), 40 | status=200, 41 | ) 42 | # Test that the Tado home can be set to auto geofencing mode when it is 43 | # supported and currently in manual mode. 44 | home_state = self.tado_client.get_home_state() 45 | assert home_state.show_switch_to_auto_geofencing_button is True 46 | assert home_state.presence_locked is True 47 | self.tado_client.set_auto() 48 | 49 | @responses.activate 50 | def test_home_already_set_to_auto_mode(self): 51 | # Test that the Tado home remains set to auto geofencing mode when it is 52 | # supported, and already in auto mode. 53 | responses.add( 54 | responses.GET, 55 | "https://my.tado.com/api/v2/homes/1234/state", 56 | json=json.loads( 57 | common.load_fixture("tadov2.home_state.auto_supported.auto_mode.json") 58 | ), 59 | status=200, 60 | ) 61 | home_state = self.tado_client.get_home_state() 62 | assert home_state.presence_locked is False 63 | self.tado_client.set_auto() 64 | 65 | @responses.activate 66 | def test_home_cant_be_set_to_auto_when_home_does_not_support_geofencing(self): 67 | # Test that the Tado home can't be set to auto geofencing mode when it 68 | # is not supported. 69 | responses.add( 70 | responses.GET, 71 | "https://my.tado.com/api/v2/homes/1234/state", 72 | json=json.loads( 73 | common.load_fixture("tadov2.home_state.auto_not_supported.json") 74 | ), 75 | status=200, 76 | ) 77 | self.tado_client.get_home_state() 78 | 79 | with self.assertRaises(Exception): 80 | self.tado_client.set_auto() 81 | 82 | @responses.activate 83 | def test_get_running_times(self): 84 | """Test the get_running_times method.""" 85 | responses.add( 86 | responses.GET, 87 | "https://minder.tado.com/v1/homes/1234/runningTimes?from=2023-08-01", 88 | json=json.loads(common.load_fixture("running_times.json")), 89 | status=200, 90 | ) 91 | 92 | running_times = self.tado_client.get_running_times(date(2023, 8, 1)) 93 | 94 | assert running_times.last_updated == datetime.fromisoformat( 95 | "2023-08-05T19:50:21Z" 96 | ) 97 | assert running_times.running_times[0].zones[0].id == 1 98 | 99 | @responses.activate 100 | def test_get_boiler_install_state(self): 101 | responses.add( 102 | responses.GET, 103 | "https://my.tado.com/api/v2/homeByBridge/IB123456789/boilerWiringInstallationState?authKey=authcode", 104 | json=json.loads( 105 | common.load_fixture( 106 | "home_by_bridge.boiler_wiring_installation_state.json" 107 | ) 108 | ), 109 | status=200, 110 | ) 111 | boiler_temperature = self.tado_client.get_boiler_install_state( 112 | "IB123456789", "authcode" 113 | ) 114 | 115 | assert boiler_temperature.boiler.output_temperature.celsius == 38.01 116 | 117 | @responses.activate 118 | def test_get_boiler_max_output_temperature(self): 119 | responses.add( 120 | responses.GET, 121 | "https://my.tado.com/api/v2/homeByBridge/IB123456789/boilerMaxOutputTemperature?authKey=authcode", 122 | json=json.loads( 123 | common.load_fixture("home_by_bridge.boiler_max_output_temperature.json") 124 | ), 125 | status=200, 126 | ) 127 | 128 | boiler_temperature = self.tado_client.get_boiler_max_output_temperature( 129 | "IB123456789", "authcode" 130 | ) 131 | 132 | assert boiler_temperature.boiler_max_output_temperature_in_celsius == 50.0 133 | 134 | def test_set_boiler_max_output_temperature(self): 135 | with mock.patch( 136 | "PyTado.http.Http.request", 137 | return_value={"success": True}, 138 | ) as mock_request: 139 | response = self.tado_client.set_boiler_max_output_temperature( 140 | "IB123456789", "authcode", 75 141 | ) 142 | 143 | mock_request.assert_called_once() 144 | args, _ = mock_request.call_args 145 | request: TadoRequest = args[0] 146 | 147 | self.assertEqual(request.command, "boilerMaxOutputTemperature") 148 | self.assertEqual(request.action, "PUT") 149 | self.assertEqual( 150 | request.payload, {"boilerMaxOutputTemperatureInCelsius": 75} 151 | ) 152 | 153 | # Verify the response 154 | self.assertTrue(response.success) 155 | 156 | def test_set_flow_temperature_optimization(self): 157 | """Test the set_flow_temperature_optimization method.""" 158 | with mock.patch( 159 | "PyTado.http.Http.request", 160 | return_value=json.loads( 161 | common.load_fixture("set_flow_temperature_optimization_issue_143.json") 162 | ), 163 | ): 164 | # Set max flow temperature to 50°C 165 | self.tado_client.set_flow_temperature_optimization(50) 166 | 167 | # Verify API call was made with correct parameters 168 | assert self.tado_client._http.request.called 169 | 170 | # Verify the request had the correct payload 171 | call_args = self.tado_client._http.request.call_args 172 | request = call_args[0][0] 173 | assert request.payload == {"maxFlowTemperature": 50} 174 | 175 | @responses.activate 176 | def test_get_flow_temperature_optimization(self): 177 | responses.add( 178 | responses.GET, 179 | "https://my.tado.com/api/v2/homes/1234/flowTemperatureOptimization", 180 | json=json.loads( 181 | common.load_fixture("set_flow_temperature_optimization_issue_143.json") 182 | ), 183 | status=200, 184 | ) 185 | response = self.tado_client.get_flow_temperature_optimization() 186 | 187 | # Verify the response 188 | self.assertEqual(response.max_flow_temperature, 50) 189 | -------------------------------------------------------------------------------- /PyTado/models/pre_line_x/zone.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from pydantic import AliasChoices, Field 4 | 5 | from PyTado.const import DEFAULT_TADO_PRECISION 6 | from PyTado.models.home import Temperature, TempPrecision 7 | from PyTado.models.pre_line_x.device import Device 8 | from PyTado.models.util import Base 9 | from PyTado.types import ( 10 | FanLevel, 11 | FanSpeed, 12 | HorizontalSwing, 13 | HvacMode, 14 | LinkState, 15 | OverlayMode, 16 | Power, 17 | Presence, 18 | VerticalSwing, 19 | ZoneType, 20 | ) 21 | 22 | 23 | class DazzleMode(Base): 24 | """DazzleMode model represents the dazzle mode settings of a zone.""" 25 | 26 | supported: bool 27 | enabled: bool = False 28 | 29 | 30 | class OpenWindowDetection(Base): 31 | """OpenWindowDetection model represents the open window detection settings.""" 32 | 33 | supported: bool 34 | enabled: bool = False 35 | timeout_in_seconds: int = 0 36 | 37 | 38 | class Zone(Base): # pylint: disable=too-many-instance-attributes 39 | """Zone model represents a zone in a home.""" 40 | 41 | id: int 42 | name: str 43 | type: ZoneType 44 | date_created: datetime 45 | device_types: list[str] 46 | devices: list[Device] 47 | report_available: bool 48 | show_schedule_setup: bool 49 | supports_dazzle: bool 50 | dazzle_enabled: bool 51 | dazzle_mode: DazzleMode 52 | open_window_detection: OpenWindowDetection 53 | 54 | 55 | class TerminationCondition(Base): 56 | """TerminationCondition model represents the termination condition.""" 57 | 58 | type: OverlayMode | None = None 59 | duration_in_seconds: int | None = None 60 | 61 | 62 | class HeatingPower(Base): 63 | """HeatingPower model represents the heating power.""" 64 | 65 | percentage: float 66 | type: str | None = None # TODO: use Enum for this 67 | timestamp: datetime | None = None 68 | # TODO: Check if this is still used! 69 | value: str | None = None 70 | 71 | 72 | class AcPower(Base): 73 | """AcPower model represents the AC power.""" 74 | 75 | type: str # TODO: use Enum for this 76 | timestamp: datetime 77 | value: Power 78 | 79 | 80 | class ActivityDataPoints(Base): 81 | """ActivityDataPoints model represents the activity data points.""" 82 | 83 | ac_power: AcPower | None = None 84 | heating_power: HeatingPower | None = None 85 | 86 | 87 | class InsideTemperature(Base): 88 | """InsideTemperature model represents the temperature in Celsius and Fahrenheit.""" 89 | 90 | celsius: float 91 | fahrenheit: float 92 | precision: TempPrecision 93 | type: str | None = None 94 | timestamp: datetime | None = None 95 | 96 | 97 | class OpenWindow(Base): 98 | """OpenWindow model represents the open window settings of a zone (Pre Tado X only).""" 99 | 100 | detected_time: datetime 101 | duration_in_seconds: int 102 | expiry: datetime 103 | remaining_time_in_seconds: int 104 | 105 | 106 | class Setting(Base): 107 | """Setting model represents the setting of a zone.""" 108 | 109 | power: Power 110 | type: ZoneType | None = None 111 | mode: HvacMode | None = None 112 | temperature: Temperature | None = None 113 | fan_speed: FanSpeed | None = None 114 | fan_level: FanLevel | None = ( 115 | None # TODO: Validate if FanSpeed or FanMode is correct here 116 | ) 117 | vertical_swing: VerticalSwing | None = None 118 | horizontal_swing: HorizontalSwing | None = None 119 | light: Power | None = None 120 | is_boost: bool | None = None 121 | 122 | 123 | class Termination(Base): 124 | """Termination model represents the termination settings of a zone.""" 125 | 126 | type: OverlayMode 127 | type_skill_based_app: OverlayMode | None = None 128 | projected_expiry: datetime | None = Field( 129 | default=None, 130 | validation_alias=AliasChoices("projectedExpiry", "projected_expiry", "expiry"), 131 | ) 132 | remaining_time_in_seconds: int | None = Field( 133 | default=None, 134 | validation_alias=AliasChoices( 135 | "remainingTimeInSeconds", 136 | "remaining_time_in_seconds", 137 | "durationInSeconds", 138 | "duration_in_seconds", 139 | ), 140 | ) 141 | 142 | 143 | class Overlay(Base): 144 | """Overlay model represents the overlay settings of a zone.""" 145 | 146 | type: OverlayMode 147 | setting: Setting 148 | termination: Termination | None = None 149 | 150 | 151 | class NextScheduleChange(Base): 152 | """NextScheduleChange model represents the next schedule change.""" 153 | 154 | start: datetime 155 | setting: Setting 156 | 157 | 158 | class LinkReason(Base): 159 | """LinkReason model represents the reason of a link state.""" 160 | 161 | code: str 162 | title: str 163 | 164 | 165 | class Link(Base): 166 | """Link model represents the link of a zone.""" 167 | 168 | state: LinkState 169 | reason: LinkReason | None = None 170 | 171 | 172 | class Humidity(Base): 173 | """Humidity model represents the humidity.""" 174 | 175 | percentage: float 176 | type: str | None = None 177 | timestamp: datetime | None = None 178 | 179 | 180 | class SensorDataPoints(Base): 181 | """SensorDataPoints model represents the sensor data points.""" 182 | 183 | inside_temperature: InsideTemperature | None = None 184 | humidity: Humidity | None = None 185 | 186 | 187 | class NextTimeBlock(Base): 188 | """NextTimeBlock model represents the next time block.""" 189 | 190 | start: datetime 191 | 192 | 193 | class ZoneState(Base): # pylint: disable=too-many-instance-attributes 194 | """ZoneState model represents the state of a zone.""" 195 | 196 | tado_mode: Presence 197 | geolocation_override: bool 198 | geolocation_override_disable_time: str | None = None 199 | preparation: str | None = None 200 | setting: Setting 201 | overlay_type: str | None = None 202 | overlay: Overlay | None = None 203 | open_window: OpenWindow | None = None 204 | next_schedule_change: NextScheduleChange | None = None 205 | next_time_block: NextTimeBlock 206 | link: Link 207 | running_offline_schedule: bool | None = None 208 | activity_data_points: ActivityDataPoints 209 | sensor_data_points: SensorDataPoints 210 | termination_condition: Termination | None = None 211 | 212 | 213 | class Duties(Base): 214 | """Duties model represents the duties configuration of a zone control.""" 215 | 216 | type: str 217 | leader: Device 218 | drivers: list[Device] 219 | uis: list[Device] 220 | 221 | 222 | class ZoneControl(Base): 223 | """ZoneControl model represents the control settings of a zone.""" 224 | 225 | type: str 226 | early_start_enabled: bool 227 | heating_circuit: int 228 | duties: Duties 229 | 230 | 231 | class ZoneOverlayDefault(Base): 232 | """ZoneOverlayDefault model represents the default overlay settings of a zone.""" 233 | 234 | termination_condition: Termination 235 | 236 | 237 | class TemperatureCapabilitiesValues(Base): 238 | min: float 239 | max: float 240 | step: float = DEFAULT_TADO_PRECISION 241 | 242 | 243 | class TemperatureCapability(Base): 244 | celsius: TemperatureCapabilitiesValues 245 | fahrenheit: TemperatureCapabilitiesValues | None = None 246 | 247 | 248 | class AirConditioningModeCapabilities(Base): 249 | fan_level: list[FanLevel] | None = None 250 | vertical_swing: list[VerticalSwing] | None = None 251 | horizontal_swing: list[HorizontalSwing] | None = None 252 | light: list[Power] | None = None 253 | temperatures: TemperatureCapability | None = None 254 | 255 | 256 | class AirConditioningZoneSetting(Base): 257 | fan_level: FanLevel | None = None 258 | vertical_swing: VerticalSwing | None = None 259 | horizontal_swing: HorizontalSwing | None = None 260 | light: Power | None = None 261 | temperature: Temperature | None = None 262 | 263 | 264 | class AirConditioningInitialStates(Base): 265 | mode: HvacMode 266 | modes: dict[HvacMode, AirConditioningZoneSetting] 267 | 268 | 269 | class Capabilities(Base): 270 | type: ZoneType 271 | temperatures: TemperatureCapability | None = None 272 | can_set_temperature: bool | None = None 273 | auto: AirConditioningModeCapabilities | None = Field( 274 | default=None, validation_alias=AliasChoices("auto", "AUTO") 275 | ) 276 | heat: AirConditioningModeCapabilities | None = Field( 277 | default=None, validation_alias=AliasChoices("heat", "HEAT") 278 | ) 279 | cool: AirConditioningModeCapabilities | None = Field( 280 | default=None, validation_alias=AliasChoices("cool", "COOL") 281 | ) 282 | fan: AirConditioningModeCapabilities | None = Field( 283 | default=None, validation_alias=AliasChoices("fan", "FAN") 284 | ) 285 | dry: AirConditioningModeCapabilities | None = Field( 286 | default=None, validation_alias=AliasChoices("dry", "DRY") 287 | ) 288 | initial_states: AirConditioningInitialStates | None = None 289 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PyTado -- Pythonize your central heating 2 | 3 | [![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) 4 | [![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) 5 | [![PyPI version](https://badge.fury.io/py/python-tado.svg)](https://badge.fury.io/py/python-tado) 6 | [![codecov](https://codecov.io/github/wmalgadey/PyTado/graph/badge.svg?token=14TT00IWJI)](https://codecov.io/github/wmalgadey/PyTado) 7 | [![Open in Dev Containers][devcontainer-shield]][devcontainer] 8 | 9 | PyTado is a Python module implementing an interface to the Tado web API. It allows a user to interact with their 10 | Tado heating system for the purposes of monitoring or controlling their heating system, beyond what Tado themselves 11 | currently offer. 12 | 13 | It is hoped that this module might be used by those who wish to tweak their Tado systems, and further optimize their 14 | heating setups. 15 | 16 | --- 17 | 18 | Original author: Chris Jewell 19 | 20 | License: GPL v3 21 | 22 | Copyright: Chris Jewell 2016-2018 23 | 24 | ## Disclaimer 25 | 26 | Besides owning a Tado system, I have no connection with the Tado company themselves. PyTado was created for my own use, 27 | and for others who may wish to experiment with personal Internet of Things systems. I receive no help (financial or 28 | otherwise) from Tado, and have no business interest with them. This software is provided without warranty, according to 29 | the GNU Public License version 3, and should therefore not be used where it may endanger life, financial stakes, or 30 | cause discomfort and inconvenience to others. 31 | 32 | ## Usage 33 | 34 | As of the 15th of March 2025, Tado has updated their OAuth2 authentication flow. It will now use the device flow, instead of a username/password flow. This means that the user will have to authenticate the device using a browser, and then enter the code that is displayed on the browser into the terminal. 35 | 36 | PyTado handles this as following: 37 | 38 | 1. The `_login_device_flow()` will be invoked at the initialization of a PyTado object. This will start the device flow and will return a URL and a code that the user will have to enter in the browser. The URL can be obtained via the method `device_verification_url()`. Or, when in debug mode, the URL will be printed. Alternatively, you can use the `device_activation_status()` method to check if the device has been activated. It returns three statuses: `NOT_STARTED`, `PENDING`, and `COMPLETED`. Wait to invoke the `device_activation()` method until the status is `PENDING`. 39 | 40 | 2. Once the URL is obtained, the user will have to enter the code that is displayed on the browser into the terminal. By default, the URL has the `user_code` attached, for the ease of going trough the flow. At this point, run the method `device_activation()`. It will poll every five seconds to see if the flow has been completed. If the flow has been completed, the method will return a token that will be used for all further requests. It will timeout after five minutes. 41 | 42 | 3. Once the token has been obtained, the user can use the PyTado object to interact with the Tado API. The token will be stored in the `Tado` object, and will be used for all further requests. The token will be refreshed automatically when it expires. 43 | The `device_verification_url()` will be reset to `None` and the `device_activation_status()` will return `COMPLETED`. 44 | 45 | ### Screenshots of the device flow 46 | 47 | ![Tado device flow: invoking](/screenshots/tado-device-flow-0.png) 48 | ![Tado device flow: browser](/screenshots/tado-device-flow-1.png) 49 | ![Tado device flow: complete](/screenshots/tado-device-flow-2.png) 50 | 51 | ### How to not authenticate the device again 52 | 53 | It is possible to save the refresh token and reuse to skip the next login. 54 | 55 | The following code will use the `refresh_token` file to save the refresh-token after login, and load the refresh-token if you create the Tado interface class again. 56 | 57 | If the file doesn't exists, the web browser is started and the device authentication url is automatically opened. You can activate the device in the browser. When you restart the program, the refresh-token is reused and no web browser will be opened. 58 | 59 | ```python 60 | import webbrowser # only needed for direct web browser access 61 | 62 | from PyTado.interface.interface import Tado 63 | 64 | tado = Tado(token_file_path="/var/tado/refresh_token") 65 | 66 | status = tado.device_activation_status() 67 | 68 | if status == "PENDING": 69 | url = tado.device_verification_url() 70 | 71 | webbrowser.open_new_tab(url) 72 | 73 | tado.device_activation() 74 | 75 | status = tado.device_activation_status() 76 | 77 | if status == "COMPLETED": 78 | print("Login successful") 79 | else: 80 | print(f"Login status is {status}") 81 | ``` 82 | 83 | ## Example code 84 | 85 | ```python 86 | """Example client for PyTado""" 87 | 88 | from PyTado.interface.interface import Tado 89 | 90 | 91 | def main() -> None: 92 | """Retrieve all zones, once successfully logged in""" 93 | tado = Tado() 94 | 95 | print("Device activation status: ", tado.device_activation_status()) 96 | print("Device verification URL: ", tado.device_verification_url()) 97 | 98 | print("Starting device activation") 99 | tado.device_activation() 100 | 101 | print("Device activation status: ", tado.device_activation_status()) 102 | 103 | zones = tado.get_zones() 104 | print(zones) 105 | 106 | 107 | if __name__ == "__main__": 108 | main() 109 | ``` 110 | 111 | Note: For developers, there is an `example.py` script in `examples/` which is configured to fetch data from your account. 112 | You can then invoke `python examples/example.py`. 113 | 114 | ## Contributing 115 | 116 | We are very open to the community's contributions - be it a quick fix of a typo, or a completely new feature! 117 | 118 | You don't need to be a Python expert to provide meaningful improvements. To learn how to get started, check out our 119 | [Contributor Guidelines](https://github.com/wmalgadey/econnect-python/blob/main/CONTRIBUTING.md) first, and ask for help 120 | in [GitHub Discussions](https://github.com/wmalgadey/PyTado/discussions) if you have questions. 121 | 122 | ## Development 123 | 124 | We welcome external contributions, even though the project was initially intended for personal use. If you think some 125 | parts could be exposed with a more generic interface, please open a [GitHub issue](https://github.com/wmalgadey/PyTado/issues) 126 | to discuss your suggestion. 127 | 128 | ### Setting up a devcontainer 129 | 130 | The easiest way to start, is by opening a CodeSpace here on GitHub, or by using 131 | the [Dev Container][devcontainer] feature of Visual Studio Code. 132 | 133 | [![Open in Dev Containers][devcontainer-shield]][devcontainer] 134 | 135 | ### Dev Environment 136 | 137 | To contribute to this repository, you should first clone your fork and then setup your development environment. Clone 138 | your repository as follows (replace yourusername with your GitHub account name): 139 | 140 | ```bash 141 | git clone https://github.com/yourusername/PyTado.git 142 | cd PyTado 143 | ``` 144 | 145 | Then, to create your development environment and install the project with its dependencies, execute the `./scripts/bootstrap` script. 146 | 147 | ### Coding Guidelines 148 | 149 | To maintain a consistent codebase, we utilize [black][1]. Consistency is crucial as it helps readability, reduces errors, 150 | and facilitates collaboration among developers. 151 | 152 | To ensure that every commit adheres to our coding standards, we've integrated [pre-commit hooks][2]. These hooks 153 | automatically run `black` before each commit, ensuring that all code changes are automatically checked and formatted. 154 | 155 | For details on how to set up your development environment to make use of these hooks, please refer to the 156 | [Development][3] section of our documentation. 157 | 158 | [1]: https://github.com/ambv/black 159 | [2]: https://pre-commit.com/ 160 | [3]: https://github.com/wmalgadey/PyTado#development 161 | 162 | ### Testing 163 | 164 | Ensuring the robustness and reliability of our code is paramount. Therefore, all contributions must include at least one 165 | test to verify the intended behavior. 166 | 167 | To run tests locally, execute the test suite using `pytest` with the following command: 168 | 169 | ```bash 170 | pytest tests/ --cov --cov-branch -vv 171 | ``` 172 | 173 | --- 174 | 175 | A message from the original author: 176 | 177 | > This software is at a purely experimental stage. If you're interested and can write Python, clone the Github repo, 178 | > drop me a line, and get involved! 179 | > 180 | > Best wishes and a warm winter to all! 181 | > 182 | > Chris Jewell 183 | 184 | [devcontainer-shield]: https://img.shields.io/static/v1?label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode 185 | [devcontainer]: https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/wmalgadey/PyTado 186 | --------------------------------------------------------------------------------