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