Repository: wmalgadey/PyTado
Pull request #157: OAuth device flow
Author: login: erwindouna
Labels: name: enhancement
Created: 2025-02-18T19:26:50Z | Merged: 2025-03-11T12:11:41Z

## Description
In accordance with Tado itself, we're upgrading the login flow to step away from a traditional username and password, to their desired device flow.
This has been noticed in: https://github.com/wmalgadey/PyTado/issues/155

This is the basic working prototype, that refactors the Http class. 


---

## Related Issues
Link any related issues that this pull request resolves or is associated with:

**Example:**
- Closes #155
- Related to #155 

---

## Type of Changes
Mark the type of changes included in this pull request:

- [ ] Bugfix
- [x] New Feature
- [ ] Documentation Update
- [x] Refactor
- [ ] Other (please specify):

---

## Checklist
- [ ] I have tested the changes locally and they work as expected.
- [ ] I have added/updated necessary documentation (if applicable).
- [ ] I have followed the code style and guidelines of the project.
- [ ] I have searched for and linked any related issues.

---

## Additional Notes
Add any additional comments, screenshots, or context for the reviewer(s).

---

Thank you for your contribution to PyTado! 🎉


Linked issue #155: [Notice] Deprecation/removal of password flow support from auth.tado.com
Hi, Mike from tado GmbH. We wanted to let you know that we will be removing support for the password flow on our Oauth server. We will be removing the functionality by **15 March 2025**.

The replacement for this is to use the device code flow (for more info see [here](https://oauth.net/2/grant-types/device-code/) or [here](https://datatracker.ietf.org/doc/html/rfc8628)). This flow is more secure and more appropriate for dealing with use cases where there is limited access to a browser and/or there is no possibility to support the redirect in an authorization code flow (e.g. on the command line, in a script). A rough example how to do this follows (provided "as-is"). Note, this can, and should, be used in tandem with the refresh token flow.

For more information, see also this [post](https://support.tado.com/en/articles/8565472-how-do-i-authenticate-to-access-the-rest-api) on the tado support page.
Linked issue comments: user: login: mgmarino
created_at: 2025-02-12T10:16:24Z
body: ```python
import time
import requests

client_id = "1bb50063-6b0c-4d11-bd99-387f4a91cc46"
base_url = "https://login.tado.com/"


def get_tokens():
    req = requests.post(
        f"{base_url}/oauth2/device_authorize",
        params=dict(
            client_id=client_id,
            scope="offline_access",
        ),
    )

    req.raise_for_status()
    req_json = req.json()

    print(
        f"""
Device code flow begun, please visit:
          
          {req_json['verification_uri_complete']}

to complete the process.
"""
    )
    
    # maximum time to wait as set by how long this temporary token is valid 
    max_time = time.time() + req_json["expires_in"] # in seconds
    while time.time() < max_time:
        # Polling faster than this interval will result in an error 
        time.sleep(req_json["interval"]) # wait time in seconds

        req = requests.post(
            f"{base_url}/oauth2/token",
            params=dict(
                client_id=client_id,
                device_code=req_json["device_code"],
                grant_type="urn:ietf:params:oauth:grant-type:device_code",
            ),
        )

        if req.status_code == 200:
            return req.json()

    print("Device code timed out...")
```
author_association: NONE
user: login: erwindouna
created_at: 2025-02-18T08:57:33Z
body: @wmalgadey I will pick this up in the coming days.
author_association: COLLABORATOR
user: login: erwindouna
created_at: 2025-02-18T12:23:56Z
body: @mgmarino which client ID should be used? Maybe a dedicated client ID that is fixed for PyTado and thus Home Assistant logins? For now, I use the provided client ID in your code snippet for development purposes.
author_association: COLLABORATOR
user: login: mgmarino
created_at: 2025-02-18T12:34:37Z
body: @erwindouna let me discuss internally if it makes sense to generate one for HA/pyTado. As it is public, we can't keep people from only using it with other non-PyTado or non-HA clients, so there might not be much value. At the moment, the client id in the code here is designated as our "public" client for these purposes, so please just use this for now.
author_association: NONE
user: login: erwindouna
created_at: 2025-02-18T12:39:17Z
body: Thanks, @mgmarino. My main aim would be that we don't have a client ID that changes. Let me know what works best for you at Tado. 😄 
author_association: COLLABORATOR
user: login: mgmarino
created_at: 2025-02-18T12:51:18Z
body: @erwindouna ok, quick discussion internally: please continue to use the one referenced here: `1bb50063-6b0c-4d11-bd99-387f4a91cc46`

Yes, we do not plan to change this.
author_association: NONE
user: login: eifinger
created_at: 2025-02-18T12:54:03Z
body: @mgmarino I think this library should send its name and version as a UserAgent and it should be overridable, so that Homeassistant can set its name and version. This would give us "good enough" information in case of incidents in combination with the clientID.

@erwindouna I think we already talked about this.
author_association: NONE
user: login: erwindouna
created_at: 2025-02-18T13:41:27Z
body: > [<img alt="" width="16" height="16" src="https://avatars.githubusercontent.com/u/124705?u=c45cfe8808066adb3cf98e103b75c799c30e0fe2&amp;v=4&amp;size=80">@mgmarino](https://github.com/mgmarino) I think this library should send its name and version as a UserAgent and it should be overridable, so that Homeassistant can set its name and version. This would give us "good enough" information in case of incidents in combination with the clientID.
> 
> [<img alt="" width="16" height="16" src="https://avatars.githubusercontent.com/u/5011203?u=a5fe84dbd3c0a45ce38a1bda17e1de43bfdb314a&amp;v=4&amp;size=80">@erwindouna](https://github.com/erwindouna) I think we already talked about this.

Yes we did, it was in place within `tadoasync`. We'll need to update within PyTado. I will work out your request. :)
author_association: COLLABORATOR
user: login: erwindouna
created_at: 2025-02-18T14:48:59Z
body: In terms of backward compatibility: will the tokens granted via the password flow be made invalid from the 15th of March?
author_association: COLLABORATOR
user: login: mgmarino
created_at: 2025-02-18T14:59:40Z
body: A few things:

- If the refresh token flow is also being used, then the existing refresh tokens can still be used to get new access tokens/refresh tokens. This should allow users making use of the refresh token grant flow to remain connected, at least for a time. However, we will *eventually* completely remove the client currently being used (`tado-web-app`), but the timeline here is not yet set. 
- Any new calls to the password flow will be denied. 
- Current refresh tokens are not usable with the new client (i.e. you can not use a refresh token obtained with the `tado-web-app` client with the `1bb50063-6b0c-4d11-bd99-387f4a91cc46` client). As such, users *will* need to relink.


author_association: NONE
user: login: clambin
created_at: 2025-02-25T11:28:46Z
body: @mgmarino: unless I'm misunderstanding, this change will make it very hard to write a server that wants to consume the Tado API, right?  any restart of the server will need manual interaction from an operator. what's the alternative?
author_association: NONE
user: login: mgmarino
created_at: 2025-02-25T11:34:27Z
body: As I mentioned, if you expect long-lived access, then the refresh token + refresh flow needs to be used. 

Of course, if you want the connection to last through a script/server crash or ending, then that refresh token needs to be persisted so that it is still available. 

HTH


author_association: NONE
user: login: erwindouna
created_at: 2025-02-25T11:44:24Z
body: Within Home Assistant the entire PyTado object is stored, within HA's storage. Therefore a refresh token should be available upon restart.
Or am I overlooking your statement @clambin?
author_association: COLLABORATOR
user: login: clambin
created_at: 2025-02-25T12:45:01Z
body: my question wasn't for home assistant, but more generally, for anyone who writes a server for the Tado API.

I'm not worried about long running access: refresh tokens indeed take care of that (Oauth2 libraries can even do it for you).  I'm concerned about access on start up:  when a server starts up, it needs human interaction to get a token. And as soon as the server restarts:  either you ask the user again, or you implement persistence.

This makes things way more complicated to both implement and operate.  

Apparently Home Assistance already has persistence, but that's not a given for other applications (including PyTado users, I guess).  Even Home Assistance will now need to ask all Tado users for manual intervention at least once.

From reading the post, this seems to be driven by application owners not using the refresh flow?  They simply kept hitting the user/password flow?  So this change is meant to drive users to adopt the refresh flow?  Seems like a rate limit on the username/password flow could have driven the same behaviour, rather than forcing manual actions on every application.

Not trying to be antagonistic here.  Just concerned.  I understand that this will be ok for interactive applications, etc.  But a server becomes a major hassle to operate: have to check the server wasn't restarted, have to fish the device auth link out of a logfile, check that the link is still relevant, restart any flows that are dependent on a connection, etc etc etc.  It's ... messy.  


author_association: NONE
user: login: mgmarino
created_at: 2025-02-25T13:33:32Z
body: Let me make a few points:

- The password flow is an inherently insecure grant since it enables the application to directly handle user credentials. Current security best practices demand removing its use entirely and we take these best practices seriously.
- None of the official tado applications that we support require the password flow anymore, hence for security reasons we want to drop this completely.
- Whereas we do *not* officially support our API **as a product** or to be called in this way, we also have tried not to stand in the way. The latter is why we are providing an alternative as opposed to simply shutting it off. I would like to emphasize that tado does not charge for API calls, but API calls do have financial impact for us.
author_association: NONE
user: login: paulvha
created_at: 2025-03-02T16:27:23Z
body: First of all **thank you very much for making this code.** Recently I have installed Tado X and I am still learning how to use the API. This is code is very solid and very helpful.

With respect to the new login process I have made some changes in **http.py** to get it to work.

New functions have been added _new_get_login() and _new_refresh_token(). Updates have been made to get_login(), refresh_token() as _set_oauth_header() as well as some global variables and imports

I have kept **backward compatibility** so you can still use the username and password to login. This makes it also easier to test, as long as Tado supports it.

When **password is empty** it will follow the new rule as with the code mentioned above. When **username is empty** it will prompt the link as in the code above. If **username is not empty**, it will try to launch the webbrowser with the link on the local PC. To save time for users. The _new_refresh_token() content is mainly taken from an link that Mike shared in an earlier topic.

To even further save time the **refresh-code is stored in a local file.** When starting the application again, it will check that the content of the file is not older that 9 min. In that case the refresh information from the file is used and  the user is not asked to login.

For the adjusted http.py  see later post for updated version based on new information

P.s. I did not make a pull request as often people are like me and have a preference on how my sources should look like.




author_association: NONE
user: login: mgmarino
created_at: 2025-03-03T06:56:55Z
body: @clambin I saw your linked PR to this issue, and I wanted to post here centrally to make sure to avoid confusion regarding the lifetime of the tokens. You noted:

> Note: tokens have a lifetime of 10 minutes, meaning if tado is not running for 10 minutes, you will need to confirm the login again.

The *access* tokens are valid for ten minutes, but the refresh token is valid for longer and can be used up to 30 days after it was issued. During the refresh grant flow, the current refresh token *is* revoked, but the new one returned also is valid up to 30 days from the time of issue (or until it is used). As such, it is not necessary to force a relogin within that 30 day period.

HTH

**EDIT**: we updated the linked support page with this information.
author_association: NONE
user: login: paulvha
created_at: 2025-03-03T09:47:13Z
body: 
Thanks for the clarification about the 10 minutes and refresh token time line. That is even better for the users. With each refresh of the access code there is a new refresh code. So in theory you only need to login once, unless  you are not connecting to Tado for > 30 days.  Then you need to repeat again. An easy change to make.

Maybe update the Tado support page as well to reflect that. (https://support.tado.com/en/articles/8565472-how-do-i-authenticate-to-access-the-rest-api)

Attached an adjusted http.py version that checks that the refresh file was modified less than 29 days ago. If so it will use the refresh token to obtain the access code. Else it will remove the file,  trigger a new login and create a new refresh file.

**edit :** Updated http.py

[http.zip](https://github.com/user-attachments/files/19072551/http.zip)

author_association: NONE
user: login: clambin
created_at: 2025-03-03T13:33:11Z
body: > [@clambin](https://github.com/clambin) I saw your linked PR to this issue, and I wanted to post here centrally to make sure to avoid confusion regarding the lifetime of the tokens. You noted:
> 
> > Note: tokens have a lifetime of 10 minutes, meaning if tado is not running for 10 minutes, you will need to confirm the login again.
> 
> The _access_ tokens are valid for ten minutes, but the refresh token is valid for longer and can be used up to 30 days after it was issued. During the refresh grant flow, the current refresh token _is_ revoked, but the new one returned also is valid up to 30 days from the time of issue (or until it is used). As such, it is not necessary to force a relogin within that 30 day period.

Thanks for taking the time to review that PR. Much appreciated! I hadn't made any assumption on the expiration of the refresh token, but if we know it will always be valid for 30 days, I'll adapt accordingly, as that definitely helps.

author_association: NONE
user: login: erwindouna
created_at: 2025-03-03T15:12:34Z
body: > 
> Thanks for the clarification about the 10 minutes and refresh token time line. That is even better for the users. With each refresh of the access code there is a new refresh code. So in theory you only need to login once, unless  you are not connecting to Tado for > 30 days.  Then you need to repeat again. An easy change to make.
> 
> Maybe update the Tado support page as well to reflect that. (https://support.tado.com/en/articles/8565472-how-do-i-authenticate-to-access-the-rest-api)
> 
> Attached an adjusted http.py version that checks that the refresh file was modified less than 29 days ago. If so it will use the refresh token to obtain the access code. Else it will remove the file,  trigger a new login and create a new refresh file.
> 
> [http.zip](https://github.com/user-attachments/files/19052406/http.zip)
> 

Thanks for contributing. We already have a PR working on this topic. Feel free to review this one and leave your feedback. :)
author_association: COLLABORATOR
user: login: erwindouna
created_at: 2025-03-07T07:40:55Z
body: @mgmarino is it possible we can get an extra grace period? Currently we're struggling to get it finalized due to aligning activities with multiple people and private appointments. The code is 90% done and needs to be released.
After that it still needs to be put in Home Assistant and we're not in control of that release cycle.
I hope there's an opportunity we can get an extra grace period to get some more time in working this out 
author_association: COLLABORATOR
user: login: mgmarino
created_at: 2025-03-07T07:49:45Z
body: @erwindouna Thanks for pinging me and I appreciate that you have been working on getting this done. We should be notifying the users of the password flow directly soon (likely today) via email and will quote to them a date two weeks from the date of the email. As such, that should give you at least until 2 weeks from today. (I won't change the date on this issue to avoid complexity.) Of course, we won't be able to push this indefinitely into the future. 

That being said, I'm pretty sure in most cases HA *should* remain connected even in the case we remove it since the relogin via password flow is, I believe, more of a fallback, and most reauth is happening (correctly) with the refresh token.
author_association: NONE
user: login: paulvha
created_at: 2025-03-07T08:57:36Z
body: @mgmarino:
With respect to the refresh code, _**I believe, more of a fallback, and most reauth is happening (correctly) with the refresh token.**_
Does this mean that the "old/current" refresh-code will continue to work with the "old/current " refresh process/code  and ONLY the login is changing in March to a new approach ? If so, is there an end date set for that refresh process/code ?
author_association: NONE
user: login: paulvha
created_at: 2025-03-07T08:59:27Z
body: @erwindouna:
where can I find the new PR-code so I can help to do a "real" user test

author_association: NONE
user: login: mgmarino
created_at: 2025-03-07T09:04:17Z
body: @paulvha ☝  https://github.com/wmalgadey/PyTado/issues/155#issuecomment-2665965662

Refresh will work with current tokens for now, but we will drop the client soon. No explicit timeline yet.




author_association: NONE
user: login: erwindouna
created_at: 2025-03-07T09:10:07Z
body: > [@erwindouna](https://github.com/erwindouna): where can I find the new PR-code so I can help to do a "real" user test

Working code is in the following PR: https://github.com/wmalgadey/PyTado/pull/157
author_association: COLLABORATOR
user: login: erwindouna
created_at: 2025-03-07T09:11:43Z
body: > [@erwindouna](https://github.com/erwindouna) Thanks for pinging me and I appreciate that you have been working on getting this done. We should be notifying the users of the password flow directly soon (likely today) via email and will quote to them a date two weeks from the date of the email. As such, that should give you at least until 2 weeks from today. (I won't change the date on this issue to avoid complexity.) Of course, we won't be able to push this indefinitely into the future.
> 
> That being said, I'm pretty sure in most cases HA _should_ remain connected even in the case we remove it since the relogin via password flow is, I believe, more of a fallback, and most reauth is happening (correctly) with the refresh token.

Thanks! The extra two weeks will definitely help! 👍 
I will try to keep you posted on updates and continue to work on this PR.
author_association: COLLABORATOR
user: login: paulvha
created_at: 2025-03-07T09:57:44Z
body: > [@paulvha](https://github.com/paulvha) ☝ [#155 (comment)](https://github.com/wmalgadey/PyTado/issues/155#issuecomment-2665965662)
> 
> Refresh will work with current tokens for now, but we will drop the client soon. No explicit timeline yet.

@mgmarino:
**Thanks for the clarifications. Making the switch much better to plan and handle.**
author_association: NONE
user: login: ChrisMarriott38
created_at: 2025-03-07T13:13:06Z
body: @mgmarino  - Quick one as you seem to be in the know.

I just received the email about this. Great service to identify and alert people (and notify the HA community on here) I must say rather than just change and it be tough look.

I have a couple of thoughts. I'm in 2 minds. Currently in HA. There is the Tado integration. But also Tado can be added via HomeKit. 

The Home kit route works even if the internet is cut off to the property. Though you would need to create your own schedules and such which is a bit more work than using the Tado App/Web portal for the scheduling times. 

It's making me wonder if I should actually move more so over to HomeKit instead for HA use. And almost drop the external connection entirely.

**Local HomeKit**
 
- Not affected in Internet outage.
- Not affected by Remote server issues.
- No external calls i believe.
- HA to manage things - Issue if HA system goes down…
- Would have to make own schedules - more cumbersome.

**Remote API Route**

- Know TADO uses their own weather compensation/Solar gain and learning. But there is minimal if ANY documentation on how this is applied. I have tried to find and it seems to just be a mystery.
- Schedules controlled from the App/web portal - Though the pattern once set I believe is remembered by the gateway? So once created you don't really need to adjust. Unless it's manual intervention but this can be done temporarily from the Room stats?
- If the internet goes down, I believe it remembers it’s schedule pattern, its only if you wanted to change things it wouldn’t know those.
- heating Schedules just update and get pushed out to the System regularly as they change i believe?. 


So what I am wondering is, if once things are set, there is little intervention by tado, I'm wondering if its easier just to use HomeKit. Though I realise that people who use the Paid extra features will likely need to continue with the API route for the Energy IQ and other features like GeoFence (unless making their own in HA. 

So I wondered if you can shed any light on the Weather compensation and Solar gain, and schedules to see if it's worth considering more of the HomeKit route and 100% local inhouse control instead,

Many thanks in advance. Sorry if i have tagged this on to this thread but I just stumbled across it when coming to check if the integration was updated or being worked on.

I mean there are other bits in the Tado integration the HomeKit doesnt have i guess too like Openwindow, Early start, Heating %? and a few others. Though i guess it depends if you use those.


author_association: NONE
user: login: wmalgadey
created_at: 2025-03-07T16:42:45Z
body: For your information, for an easier and faster update of dependent integrations, we create a support-branch (https://github.com/wmalgadey/PyTado/tree/support/0.18). This branch currently contains a hotfix to version 0.18 with only the changes for the new device auth flow.

I would suggest, if the changes there work in HA (or elsewhere) we could create a release `0.18.x` and use it directly. The necessary changes from this `0.18.x`-release could be integrated into #157 afterwards.
author_association: OWNER
user: login: wmalgadey
created_at: 2025-03-07T16:45:02Z
body: > Many thanks in advance. Sorry if i have tagged this on to this thread but I just stumbled across it when coming to check if the integration was updated or being worked on.

I created a new issue to talk about that, ok? (see #169)
author_association: OWNER

Diff:
diff --git a/PyTado/const.py b/PyTado/const.py
index f9ee4ba..dddf34e 100644
--- a/PyTado/const.py
+++ b/PyTado/const.py
@@ -1,8 +1,7 @@
 """Constant values for the Tado component."""
 
-# Api credentials
-CLIENT_ID = "tado-web-app"  # nosec B105
-CLIENT_SECRET = "wZaRN7rpjn3FoNyF5IFuxg9uMzYJcvOoQ8QWiIqS3hfk6gLhVlG57j5YNoZL2Rtc"  # nosec B105
+# API client ID
+CLIENT_ID_DEVICE = "1bb50063-6b0c-4d11-bd99-387f4a91cc46"  # nosec B105
 
 # Types
 TYPE_AIR_CONDITIONING = "AIR_CONDITIONING"
@@ -97,4 +96,4 @@
 DEFAULT_TADOX_PRECISION = 0.01
 
 HOME_DOMAIN = "homes"
-DEVICE_DOMAIN = "devices"
+DEVICE_DOMAIN = "devices"
\ No newline at end of file
diff --git a/PyTado/http.py b/PyTado/http.py
index 21cc86b..f20a649 100644
--- a/PyTado/http.py
+++ b/PyTado/http.py
@@ -6,13 +6,14 @@
 import json
 import logging
 import pprint
+import time
 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.const import CLIENT_ID_DEVICE
 from PyTado.exceptions import TadoException, TadoWrongCredentialsException
 from PyTado.logger import Logger
 
@@ -56,6 +57,13 @@ class Mode(enum.Enum):
     PLAIN = 2
 
 
+class DeviceActivationStatus(enum.Enum):
+    """Device Activation Status Enum"""	
+    NOT_STARTED = "NOT_STARTED"
+    PENDING = "PENDING"
+    COMPLETED = "COMPLETED"
+
+
 class TadoRequest:
     """Data Container for my.tado.com API Requests"""
 
@@ -135,8 +143,6 @@ class Http:
 
     def __init__(
         self,
-        username: str,
-        password: str,
         http_session: requests.Session | None = None,
         debug: bool = False,
     ) -> None:
@@ -145,20 +151,37 @@ def __init__(
         else:
             _LOGGER.setLevel(logging.WARNING)
 
-        self._refresh_at = datetime.now() + timedelta(minutes=5)
+        self._refresh_at = datetime.now() + timedelta(minutes=10)
         self._session = http_session or requests.Session()
         self._session.hooks["response"].append(self._log_response)
         self._headers = {"Referer": "https://app.tado.com/"}
-        self._username = username
-        self._password = password
-        self._id, self._token_refresh = self._login()
 
-        self._x_api = self._check_x_line_generation()
+        self._user_code: str | None = None
+        self._device_verification_url: str | None = None
+        self._device_activation_status = DeviceActivationStatus.NOT_STARTED
+        self._expires_at: datetime | None = None
+
+        self._id: int | None = None
+        self._token_refresh: str | None = None
+        self._x_api: bool | None = None
+        self._device_activation_status = self._login_device_flow()
 
     @property
-    def is_x_line(self) -> bool:
+    def is_x_line(self) -> bool | None:
         return self._x_api
 
+    @property
+    def user_code(self) -> str | None:
+        return self._user_code
+
+    @property
+    def device_activation_status(self) -> DeviceActivationStatus:
+        return self._device_activation_status
+
+    @property
+    def device_verification_url(self) -> str | None:
+        return self._device_verification_url
+
     def _log_response(self, response: requests.Response, *args, **kwargs) -> None:
         og_request_method = response.request.method
         og_request_url = response.request.url
@@ -268,12 +291,10 @@ def _refresh_token(self) -> None:
         if self._refresh_at >= datetime.now():
             return
 
-        url = "https://auth.tado.com/oauth/token"
+        url = "https://login.tado.com/oauth2/token"
         data = {
-            "client_id": CLIENT_ID,
-            "client_secret": CLIENT_SECRET,
+            "client_id": CLIENT_ID_DEVICE,
             "grant_type": "refresh_token",
-            "scope": "home.user",
             "refresh_token": self._token_refresh,
         }
         self._session.close()
@@ -298,29 +319,27 @@ def _refresh_token(self) -> None:
 
         if response.status_code != 200:
             raise TadoWrongCredentialsException(
-                "Failed to refresh token, probably wrong credentials. "
-                f"Status code: {response.status_code}"
+                "Failed to refresh token, probably wrong credentials. " f"Status code: {response.status_code}"
             )
 
         self._set_oauth_header(response.json())
 
-    def _login(self) -> tuple[int, str]:
-        """Login to the API and get the refresh token"""
+    def _login_device_flow(self) -> DeviceActivationStatus:
+        """Start the login to the API using the device flow"""
+
+        if self._device_activation_status != DeviceActivationStatus.NOT_STARTED:
+            raise TadoException("The device has been started already")
 
-        url = "https://auth.tado.com/oauth/token"
+        url = "https://login.tado.com/oauth2/device_authorize"
         data = {
-            "client_id": CLIENT_ID,
-            "client_secret": CLIENT_SECRET,
-            "grant_type": "password",
-            "password": self._password,
-            "scope": "home.user",
-            "username": self._username,
+            "client_id": CLIENT_ID_DEVICE,
+            "scope": "offline_access",
         }
 
         try:
             response = self._session.request(
-                "post",
-                url,
+                method="post",
+                url=url,
                 params=data,
                 timeout=_DEFAULT_TIMEOUT,
                 data=json.dumps({}).encode("utf8"),
@@ -330,21 +349,77 @@ def _login(self) -> tuple[int, str]:
                 },
             )
         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 TadoException(e) from e
 
         if response.status_code != 200:
-            raise TadoException(
-                f"Login failed for unknown reason with status code {response.status_code}"
+            raise TadoException(f"Login failed. Status code: {response.status_code} and reason: {response.reason}")
+
+        self._device_flow_data = response.json()
+        _LOGGER.debug("Device flow response: %s", self._device_flow_data)
+
+        user_code = urlencode({"user_code": self._device_flow_data["user_code"]})
+        visit_url = f"{self._device_flow_data['verification_uri']}?{user_code}"
+        self._user_code = self._device_flow_data["user_code"]
+        self._device_verification_url = visit_url
+
+        _LOGGER.info("Please visit the following URL: %s", visit_url)
+
+        expires_in_seconds = self._device_flow_data["expires_in"]
+        self._expires_at = datetime.now() + timedelta(seconds=expires_in_seconds)
+
+        _LOGGER.info(
+            "Waiting for user to authorize the device. Expires at %s",
+            self._expires_at.strftime("%Y-%m-%d %H:%M:%S"),
+        )
+
+        return DeviceActivationStatus.PENDING
+
+    def _check_device_activation(self) -> bool:
+        if self._expires_at is not None and datetime.timestamp(datetime.now()) > datetime.timestamp(self._expires_at):
+            raise TadoException("User took too long to enter key")
+
+        # Await the desired interval, before polling the API again
+        time.sleep(self._device_flow_data["interval"])
+
+        try:
+            token_response = self._session.request(
+                method="post",
+                url="https://login.tado.com/oauth2/token",
+                params={
+                    "client_id": CLIENT_ID_DEVICE,
+                    "device_code": self._device_flow_data["device_code"],
+                    "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
+                },
             )
+        except requests.exceptions.ConnectionError as e:
+            raise TadoException(e) from e
+
+        if token_response.status_code == 200:
+            self._set_oauth_header(token_response.json())
+            return True
+
+        # The user has not yet authorized the device, let's continue
+        if token_response.status_code == 400 and token_response.json()["error"] == "authorization_pending":
+            _LOGGER.info("Authorization pending, waiting for user to authorize. Continue polling.")
+            return False
+
+        raise TadoException(f"Login failed. Reason: {token_response.reason}")
 
-        refresh_token = self._set_oauth_header(response.json())
-        id_ = self._get_id()
+    def device_activation(self) -> None:
+        """Activate the device and get the refresh token"""
 
-        return id_, refresh_token
+        if self._device_activation_status == DeviceActivationStatus.NOT_STARTED:
+            raise TadoException("The device flow has not yet started")
+
+        while True:
+            if self._check_device_activation():
+                break
+
+        self._id = self._get_id()
+        self._x_api = self._check_x_line_generation()
+        self._user_code = None
+        self._device_verification_url = None
+        self._device_activation_status = DeviceActivationStatus.COMPLETED
 
     def _get_id(self) -> int:
         request = TadoRequest()
@@ -364,4 +439,4 @@ def _check_x_line_generation(self):
 
         home_ = self.request(request)
 
-        return "generation" in home_ and home_["generation"] == "LINE_X"
+        return "generation" in home_ and home_["generation"] == "LINE_X"
\ No newline at end of file
diff --git a/PyTado/interface/api/my_tado.py b/PyTado/interface/api/my_tado.py
index 2b1dbd8..a45a005 100644
--- a/PyTado/interface/api/my_tado.py
+++ b/PyTado/interface/api/my_tado.py
@@ -619,76 +619,4 @@ def get_running_times(self, date=datetime.datetime.now().strftime("%Y-%m-%d")) -
         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)
-
-    def set_flow_temperature_optimization(self, max_flow_temperature: float):
-        """
-        Set the flow temperature optimization.
-
-        max_flow_temperature: float, the maximum flow temperature in Celsius
-        """
-
-        request = TadoRequest()
-        request.action = Action.CHANGE
-        request.domain = Domain.HOME
-        request.command = "flowTemperatureOptimization"
-        request.payload = {"maxFlowTemperature": max_flow_temperature}
-
-        return self._http.request(request)
-
-    def get_flow_temperature_optimization(self):
-        """
-        Get the current flow temperature optimization
-        """
-
-        request = TadoRequest()
-        request.action = Action.GET
-        request.domain = Domain.HOME
-        request.command = "flowTemperatureOptimization"
-
-        return self._http.request(request)
+        return self._http.request(request)
\ No newline at end of file
diff --git a/PyTado/interface/interface.py b/PyTado/interface/interface.py
index d2079f0..d0f3df9 100644
--- a/PyTado/interface/interface.py
+++ b/PyTado/interface/interface.py
@@ -7,7 +7,8 @@
 import warnings
 
 import PyTado.interface.api as API
-from PyTado.http import Http
+from PyTado.exceptions import TadoException
+from PyTado.http import DeviceActivationStatus, Http
 
 
 def deprecated(new_func_name):
@@ -36,32 +37,47 @@ class Tado:
 
     def __init__(
         self,
-        username: str,
-        password: str,
         http_session=None,
         debug: bool = False,
     ):
         """Class Constructor"""
 
         self._http = Http(
-            username=username,
-            password=password,
             http_session=http_session,
             debug=debug,
         )
-
-        if self._http.is_x_line:
-            self._api: API.Tado | API.TadoX = API.TadoX(http=self._http, debug=debug)
-        else:
-            self._api = API.Tado(http=self._http, debug=debug)
+        self._api: API.Tado | API.TadoX | None = None
+        self._debug = debug
 
     def __getattr__(self, name):
         """Delegiert den Aufruf von Methoden an die richtige API-Client-Implementierung."""
+
+        if self._api is None:
+            raise TadoException("API is not initialized. Please complete device authentication first.")
+
         return getattr(self._api, name)
 
     # region Deprecated Methods
     # pylint: disable=invalid-name
 
+    def device_verification_url(self) -> str | None:
+        """Returns the URL for device verification."""
+        return self._http.device_verification_url
+
+    def device_activation_status(self) -> DeviceActivationStatus:
+        """Returns the status of the device activation."""
+        return self._http.device_activation_status
+
+    def device_activation(self) -> None:
+        """Activates the device."""
+        self._http.device_activation()
+
+        if self._http.device_activation_status == DeviceActivationStatus.COMPLETED:
+            if self._http.is_x_line:
+                self._api = API.TadoX(http=self._http, debug=self._debug)
+            else:
+                self._api = API.Tado(http=self._http, debug=self._debug)
+
     @deprecated("get_me")
     def getMe(self):
         """Gets home information. (deprecated)"""
@@ -301,4 +317,4 @@ def setEIQTariff(
         )
 
     # pylint: enable=invalid-name
-    # endregion
+    # endregion
\ No newline at end of file
diff --git a/PyTado/logger.py b/PyTado/logger.py
index b75fef3..b6f8e17 100644
--- a/PyTado/logger.py
+++ b/PyTado/logger.py
@@ -3,44 +3,22 @@
 """
 
 import logging
-import re
 
 
 class Logger(logging.Logger):
     """
-    This class strips out sensitive information from logs
+    This class provides a custom logger without masking sensitive information.
     """
 
-    class SensitiveFormatter(logging.Formatter):
+    class SimpleFormatter(logging.Formatter):
         """
-        Formatter that removes sensitive information in logs.
+        Simple formatter that does not remove any information in logs.
         """
 
-        @staticmethod
-        def _filter(s):
-            patterns = [
-                r"'Bearer [\w-]*\.[\w-]*\.[\w-]*'",
-                r"'access_token': '[\w-]*\.[\w-]*\.[\w-]*'",
-                r"'refresh_token': '[\w-]*\.[\w-]*\.[\w-]*'",
-                r"\?.*",
-            ]
-            try:
-                for pattern in patterns:
-                    s = re.sub(pattern, r"<MASKED>", s)
-            except (KeyError, TypeError, ValueError):
-                pass
-            return s
-
-        def format(self, record):
-            """
-            Do the actual filtering
-            """
-            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.SimpleFormatter(fmt="%(name)s :: %(levelname)-8s :: %(message)s")
         log_sh.setFormatter(log_fmt)
         self.addHandler(log_sh)
+        self.setLevel(level)
diff --git a/README.md b/README.md
index 5f60257..fb663de 100644
--- a/README.md
+++ b/README.md
@@ -29,13 +29,24 @@ otherwise) from Tado, and have no business interest with them. This software is
 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
+## Usage
+As of the 15th of March 2025, Tado has updated their OAuth2 authentication flow. It will now use the device flow, instead of a username/password flow. This means that the user will have to authenticate the device using a browser, and then enter the code that is displayed on the browser into the terminal.
 
-    >>> from PyTado.interface import Tado
-    >>> t = Tado('my@username.com', 'mypassword')
-    >>> climate = t.get_climate(zone=1)
+PyTado handles this as following:
 
-## Usage
+1. The `_login_device_flow()` will be invoked at the initialization of a PyTado object. This will start the device flow and will return a URL and a code that the user will have to enter in the browser. The URL can be obtained via the method `device_verification_url()`. Or, when in debug mode, the URL will be printed. Alternatively, you can use the `device_activation_status()` method to check if the device has been activated. It returns three statuses: `NOT_STARTED`, `PENDING`, and `COMPLETED`. Wait to invoke the `device_activation()` method until the status is `PENDING`.
+
+2. Once the URL is obtained, the user will have to enter the code that is displayed on the browser into the terminal. By default, the URL has the `user_code` attached, for the ease of going trough the flow. At this point, run the method `device_activation()`. It will poll every five seconds to see if the flow has been completed. If the flow has been completed, the method will return a token that will be used for all further requests. It will timeout after five minutes.
+
+3. Once the token has been obtained, the user can use the PyTado object to interact with the Tado API. The token will be stored in the `Tado` object, and will be used for all further requests. The token will be refreshed automatically when it expires.
+The `device_verification_url()` will be reset to `None` and the `device_activation_status()` will return `COMPLETED`.
+
+### Screenshots of the device flow
+![Tado device flow: invoking](/screenshots/tado-device-flow-0.png)
+![Tado device flow: browser](/screenshots/tado-device-flow-1.png)
+![Tado device flow: complete](/screenshots/tado-device-flow-2.png)
+
+## Example code
 ```python
 """Example client for PyTado"""
 
@@ -44,7 +55,16 @@ 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
+    tado = Tado()
+
+    print("Device activation status: ", tado.device_activation_status())
+    print("Device verification URL: ", tado.device_verification_url())
+
+    print("Starting device activation")
+    tado.device_activation()
+
+    print("Device activation status: ", tado.device_activation_status())
+
     zones = tado.get_zones()
     print(zones)
 
@@ -54,14 +74,6 @@ if __name__ == "__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`.
 
 
diff --git a/screenshots/tado-device-flow-0.png b/screenshots/tado-device-flow-0.png
new file mode 100644
index 0000000..f3315f8
Binary files /dev/null and b/screenshots/tado-device-flow-0.png differ
diff --git a/screenshots/tado-device-flow-1.png b/screenshots/tado-device-flow-1.png
new file mode 100644
index 0000000..e2807a4
Binary files /dev/null and b/screenshots/tado-device-flow-1.png differ
diff --git a/screenshots/tado-device-flow-2.png b/screenshots/tado-device-flow-2.png
new file mode 100644
index 0000000..dec402b
Binary files /dev/null and b/screenshots/tado-device-flow-2.png differ
diff --git a/tests/test_hops_zone.py b/tests/test_hops_zone.py
index 8ed8542..689a7f2 100644
--- a/tests/test_hops_zone.py
+++ b/tests/test_hops_zone.py
@@ -15,21 +15,26 @@ class TadoZoneTestCase(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_device_flow")
+        device_activation_patch = mock.patch(
+            "PyTado.http.Http.device_activation"
         )
         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()
+        device_activation_patch.start()
         is_x_line_patch.start()
         get_me_patch.start()
         self.addCleanup(login_patch.stop)
+        self.addCleanup(device_activation_patch.stop)
         self.addCleanup(is_x_line_patch.stop)
         self.addCleanup(get_me_patch.stop)
 
-        self.http = Http("my@username.com", "mypassword")
+        self.http = Http()
+        self.http.device_activation()
+        self.http._x_api = True
         self.tado_client = TadoX(self.http)
 
     def set_fixture(self, filename: str) -> None:
@@ -44,17 +49,6 @@ def check_get_state(zone_id):
         get_state_patch.start()
         self.addCleanup(get_state_patch.stop)
 
-    def set_get_devices_fixture(self, filename: str) -> None:
-        def get_devices():
-            return json.loads(common.load_fixture(filename))
-
-        get_devices_patch = mock.patch(
-            "PyTado.interface.api.TadoX.get_devices",
-            side_effect=get_devices,
-        )
-        get_devices_patch.start()
-        self.addCleanup(get_devices_patch.stop)
-
     def test_tadox_heating_auto_mode(self):
         """Test general homes response."""
 
@@ -155,35 +149,4 @@ def test_tadox_heating_manual_off(self):
         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'
-
-    @mock.patch("PyTado.http.Http.request", return_value={})
-    def test_set_window_open(self, _mock_http_request):
-        """ Test get_devices method """
-        self.set_get_devices_fixture("tadox/rooms_and_devices.json")
-
-        devices_and_rooms = self.tado_client.get_devices()
-        for room in devices_and_rooms['rooms']:
-            result = self.tado_client.set_open_window(zone=room)
-            assert isinstance(result, dict)
-
-    @mock.patch("PyTado.http.Http.request", return_value={})
-    def test_reset_window_open(self, _mock_http_request):
-        """ Test get_devices method """
-        self.set_get_devices_fixture("tadox/rooms_and_devices.json")
-
-        devices_and_rooms = self.tado_client.get_devices()
-        for room in devices_and_rooms['rooms']:
-            result = self.tado_client.reset_open_window(zone=room)
-            assert isinstance(result, dict)
+        assert mode.zone_id == 1
\ No newline at end of file
diff --git a/tests/test_http.py b/tests/test_http.py
index eb09dae..8702234 100644
--- a/tests/test_http.py
+++ b/tests/test_http.py
@@ -5,8 +5,8 @@
 import responses
 import unittest
 
-from PyTado.const import CLIENT_ID, CLIENT_SECRET
-from PyTado.exceptions import TadoException, TadoWrongCredentialsException
+from PyTado.const import CLIENT_ID_DEVICE
+from PyTado.exceptions import TadoException
 
 from . import common
 
@@ -31,6 +31,31 @@ def setUp(self):
             status=200,
         )
 
+        responses.add(
+            responses.POST,
+            "https://login.tado.com/oauth2/device_authorize",
+            json={
+                "device_code": "XXX_code_XXX",
+                "expires_in": 300,
+                "interval": 1,
+                "user_code": "7BQ5ZQ",
+                "verification_uri": "https://login.tado.com/oauth2/device",
+                "verification_uri_complete": "https://login.tado.com/oauth2/device?user_code=7BQ5ZQ",
+            },
+            status=200,
+        )
+
+        responses.add(
+            responses.POST,
+            "https://login.tado.com/oauth2/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",
@@ -52,11 +77,8 @@ def setUp(self):
     @responses.activate
     def test_login_successful(self):
 
-        instance = Http(
-            username="test_user",
-            password="test_pass",
-            debug=True,
-        )
+        instance = Http(debug=True)
+        instance.device_activation()
 
         # Verify that the login was successful
         self.assertEqual(instance._id, 1234)
@@ -67,24 +89,21 @@ def test_login_failed(self):
 
         responses.replace(
             responses.POST,
-            "https://auth.tado.com/oauth/token",
+            "https://login.tado.com/oauth2/token",
             json={"error": "invalid_grant"},
             status=400,
         )
 
         with self.assertRaises(
-            expected_exception=TadoWrongCredentialsException,
+            expected_exception=TadoException,
             msg="Your username or password is invalid",
         ):
-            Http(
-                username="test_user",
-                password="test_pass",
-                debug=True,
-            )
+            instance = Http(debug=True)
+            instance.device_activation()
 
         responses.replace(
             responses.POST,
-            "https://auth.tado.com/oauth/token",
+            "https://login.tado.com/oauth2/token",
             json={"error": "server failed"},
             status=503,
         )
@@ -93,11 +112,8 @@ def test_login_failed(self):
             expected_exception=TadoException,
             msg="Login failed for unknown reason with status code 503",
         ):
-            Http(
-                username="test_user",
-                password="test_pass",
-                debug=True,
-            )
+            instance = Http(debug=True)
+            instance.device_activation()
 
     @responses.activate
     def test_line_x(self):
@@ -111,11 +127,8 @@ def test_line_x(self):
             status=200,
         )
 
-        instance = Http(
-            username="test_user",
-            password="test_pass",
-            debug=True,
-        )
+        instance = Http(debug=True)
+        instance.device_activation()
 
         # Verify that the login was successful
         self.assertEqual(instance._id, 1234)
@@ -123,23 +136,18 @@ def test_line_x(self):
 
     @responses.activate
     def test_refresh_token_success(self):
-        instance = Http(
-            username="test_user",
-            password="test_pass",
-            debug=True,
-        )
+        instance = Http(debug=True)
+        instance.device_activation()
 
         expected_params = {
-            "client_id": CLIENT_ID,
-            "client_secret": CLIENT_SECRET,
+            "client_id": CLIENT_ID_DEVICE,
             "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",
+            "https://login.tado.com/oauth2/token",
             match=[
                 responses.matchers.query_param_matcher(expected_params),
             ],
@@ -162,16 +170,13 @@ def test_refresh_token_success(self):
 
     @responses.activate
     def test_refresh_token_failure(self):
-        instance = Http(
-            username="test_user",
-            password="test_pass",
-            debug=True,
-        )
+        instance = Http(debug=True)
+        instance.device_activation()
 
         # Mock the refresh token response with failure
         refresh_token = responses.replace(
             responses.POST,
-            "https://auth.tado.com/oauth/token",
+            "https://login.tado.com/oauth2/token",
             json={"error": "invalid_grant"},
             status=400,
         )
@@ -182,4 +187,4 @@ def test_refresh_token_failure(self):
         with self.assertRaises(TadoException):
             instance._refresh_token()
 
-        assert refresh_token.call_count == 1
+        assert refresh_token.call_count == 1
\ No newline at end of file
diff --git a/tests/test_my_tado.py b/tests/test_my_tado.py
index 1a0a792..74b711e 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, TadoRequest
+from PyTado.http import Http
 from PyTado.interface.api import Tado
 
 
@@ -15,7 +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_device_flow")
         get_me_patch = mock.patch("PyTado.interface.api.Tado.get_me")
         login_patch.start()
         get_me_patch.start()
@@ -27,7 +27,7 @@ def setUp(self) -> None:
         check_x_patch.start()
         self.addCleanup(check_x_patch.stop)
 
-        self.http = Http("my@username.com", "mypassword")
+        self.http = Http()
         self.tado_client = Tado(self.http)
 
     def test_home_set_to_manual_mode(
@@ -38,7 +38,9 @@ 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()
@@ -54,7 +56,9 @@ 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()
@@ -85,91 +89,9 @@ def test_get_running_times(self):
         with mock.patch(
             "PyTado.http.Http.request",
             return_value=json.loads(common.load_fixture("running_times.json")),
-        ) as mock_request:
+        ):
             running_times = self.tado_client.get_running_times("2023-08-01")
 
-            mock_request.assert_called_once()
-
+            assert self.tado_client._http.request.called
             assert running_times["lastUpdated"] == "2023-08-05T19:50:21Z"
-            assert running_times["runningTimes"][0]["zones"][0]["id"] == 1
-
-    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"])
-
-    def test_set_flow_temperature_optimization(self):
-        with mock.patch(
-            "PyTado.http.Http.request",
-            return_value=json.loads(
-                common.load_fixture("set_flow_temperature_optimization_issue_143.json")
-            ),
-        ) as mock_request:
-            self.tado_client.set_flow_temperature_optimization(50)
-
-            mock_request.assert_called_once()
-            args, _ = mock_request.call_args
-            request: TadoRequest = args[0]
-
-            self.assertEqual(request.command, "flowTemperatureOptimization")
-            self.assertEqual(request.action, "PUT")
-            self.assertEqual(request.payload, {"maxFlowTemperature": 50})
-
-    def test_get_flow_temperature_optimization(self):
-        with mock.patch(
-            "PyTado.http.Http.request",
-            return_value=json.loads(
-                common.load_fixture("set_flow_temperature_optimization_issue_143.json")
-            ),
-        ) as mock_request:
-            response = self.tado_client.get_flow_temperature_optimization()
-
-            mock_request.assert_called_once()
-
-            # Verify the response
-            self.assertEqual(response["maxFlowTemperature"], 50)
+            assert running_times["runningTimes"][0]["zones"][0]["id"] == 1
\ No newline at end of file
diff --git a/tests/test_my_zone.py b/tests/test_my_zone.py
index b65c482..ad4f3a6 100644
--- a/tests/test_my_zone.py
+++ b/tests/test_my_zone.py
@@ -15,7 +15,9 @@ class TadoZoneTestCase(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_device_flow", return_value=(1, "foo")
+        )
         get_me_patch = mock.patch("PyTado.interface.api.Tado.get_me")
         check_x_patch = mock.patch(
             "PyTado.http.Http._check_x_line_generation", return_value=False
@@ -28,7 +30,7 @@ def setUp(self) -> None:
         self.addCleanup(get_me_patch.stop)
         self.addCleanup(check_x_patch.stop)
 
-        self.http = Http("my@username.com", "mypassword")
+        self.http = Http()
         self.tado_client = Tado(self.http)
 
     def set_fixture(self, filename: str) -> None:
diff --git a/tests/test_tado_interface.py b/tests/test_tado_interface.py
index 6303714..b242628 100644
--- a/tests/test_tado_interface.py
+++ b/tests/test_tado_interface.py
@@ -4,7 +4,7 @@
 
 from . import common
 
-from PyTado.http import Http
+from PyTado.http import DeviceActivationStatus, Http
 from PyTado.interface import Tado
 import PyTado.interface.api as API
 
@@ -12,68 +12,70 @@
 class TestTadoInterface(unittest.TestCase):
     """Test cases for main tado interface class"""
 
+    def setUp(self):
+        super().setUp()
+
+        login_patch = mock.patch(
+            "PyTado.http.Http._login_device_flow",
+            return_value=DeviceActivationStatus.PENDING,
+        )
+        login_patch.start()
+
+        device_activation_patch = mock.patch(
+            "PyTado.http.Http._check_device_activation", return_value=True
+        )
+        device_activation_patch.start()
+
+        get_id_patch = mock.patch("PyTado.http.Http._get_id")
+        get_id_patch.start()
+
+        self.addCleanup(login_patch.stop)
+        self.addCleanup(device_activation_patch.stop)
+        self.addCleanup(get_id_patch.stop)
+
     @mock.patch("PyTado.interface.api.my_tado.Tado.get_me")
     @mock.patch("PyTado.interface.api.hops_tado.TadoX.get_me")
     def test_interface_with_tado_api(self, mock_hops_get_me, mock_my_get_me):
-        login_patch = mock.patch(
-            "PyTado.http.Http._login", return_value=(1, "foo")
-        )
-        login_mock = login_patch.start()
         check_x_patch = mock.patch(
             "PyTado.http.Http._check_x_line_generation", return_value=False
         )
         check_x_patch.start()
         self.addCleanup(check_x_patch.stop)
-        self.addCleanup(login_patch.stop)
 
-        tado_interface = Tado("my@username.com", "mypassword")
+        tado_interface = Tado()
+        tado_interface.device_activation()
         tado_interface.get_me()
 
         assert not tado_interface._http.is_x_line
+
         mock_my_get_me.assert_called_once()
         mock_hops_get_me.assert_not_called()
 
-        assert login_mock.call_count == 1
-
-    def test_interface_with_tadox_api(self):
-        login_patch = mock.patch(
-            "PyTado.http.Http._login", return_value=(1, "foo")
-        )
-        login_mock = login_patch.start()
+    @mock.patch("PyTado.interface.api.my_tado.Tado.get_me")
+    @mock.patch("PyTado.interface.api.hops_tado.TadoX.get_me")
+    def test_interface_with_tadox_api(self, mock_hops_get_me, mock_my_get_me):
         check_x_patch = mock.patch(
             "PyTado.http.Http._check_x_line_generation", return_value=True
         )
         check_x_patch.start()
         self.addCleanup(check_x_patch.stop)
 
-        with mock.patch(
-            "PyTado.interface.api.hops_tado.TadoX.get_me"
-        ) as mock_it:
-            tado_interface = Tado("my@username.com", "mypassword")
-            tado_interface.get_me()
+        tado_interface = Tado()
+        tado_interface.device_activation()
+        tado_interface.get_me()
 
-            mock_it.assert_called_once()
-            assert tado_interface._http.is_x_line
+        assert tado_interface._http.is_x_line
 
-        login_mock.assert_called_once()
+        mock_my_get_me.assert_not_called()
+        mock_hops_get_me.assert_called_once()
 
     def test_error_handling_on_api_calls(self):
-        login_patch = mock.patch(
-            "PyTado.http.Http._login", return_value=(1, "foo")
-        )
-        login_mock = login_patch.start()
-        check_x_patch = mock.patch(
-            "PyTado.http.Http._check_x_line_generation", return_value=False
-        )
-        check_x_patch.start()
-        self.addCleanup(check_x_patch.stop)
-
         with mock.patch("PyTado.interface.api.my_tado.Tado.get_me") as mock_it:
             mock_it.side_effect = Exception("API Error")
 
-            tado_interface = Tado("my@username.com", "mypassword")
+            tado_interface = Tado()
 
             with self.assertRaises(Exception) as context:
                 tado_interface.get_me()
 
-                self.assertIn("API Error", str(context.exception))
+                self.assertIn("API Error", str(context.exception))
\ No newline at end of file


Reviews:
user: login: inverse
state: COMMENTED
author_association: NONE
submitted_at: 2025-02-19T22:29:32Z
user: login: inverse
state: COMMENTED
author_association: NONE
submitted_at: 2025-02-19T23:06:33Z
user: login: inverse
state: COMMENTED
author_association: NONE
submitted_at: 2025-02-20T00:00:39Z
user: login: Moritz-Schmidt
state: COMMENTED
author_association: NONE
submitted_at: 2025-02-20T09:19:31Z
user: login: Moritz-Schmidt
state: COMMENTED
author_association: NONE
submitted_at: 2025-02-20T09:38:44Z
user: login: erwindouna
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-20T11:13:56Z
user: login: wmalgadey
state: COMMENTED
author_association: OWNER
submitted_at: 2025-02-20T14:09:58Z
user: login: erwindouna
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-22T08:33:39Z
user: login: erwindouna
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-22T08:39:15Z
user: login: wmalgadey
state: COMMENTED
author_association: OWNER
submitted_at: 2025-02-23T18:21:41Z
user: login: erwindouna
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-24T09:38:32Z
user: login: wmalgadey
state: COMMENTED
author_association: OWNER
submitted_at: 2025-02-26T19:59:20Z
user: login: wmalgadey
state: COMMENTED
author_association: OWNER
submitted_at: 2025-02-26T20:07:04Z
user: login: wmalgadey
state: COMMENTED
author_association: OWNER
submitted_at: 2025-02-26T20:07:42Z
user: login: wmalgadey
state: COMMENTED
author_association: OWNER
submitted_at: 2025-02-26T20:07:54Z
user: login: wmalgadey
state: COMMENTED
author_association: OWNER
submitted_at: 2025-02-26T20:08:01Z
user: login: wmalgadey
state: COMMENTED
author_association: OWNER
submitted_at: 2025-02-26T20:08:50Z
user: login: wmalgadey
state: COMMENTED
author_association: OWNER
submitted_at: 2025-02-26T20:08:55Z
user: login: erwindouna
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-27T08:27:57Z
user: login: wmalgadey
state: COMMENTED
author_association: OWNER
submitted_at: 2025-02-27T09:00:10Z
user: login: erwindouna
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-28T07:02:14Z
user: login: Moritz-Schmidt
state: COMMENTED
author_association: NONE
submitted_at: 2025-02-28T08:53:41Z
user: login: Moritz-Schmidt
state: COMMENTED
author_association: NONE
submitted_at: 2025-02-28T09:08:43Z
user: login: wmalgadey
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-01T12:33:58Z
user: login: wmalgadey
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-01T12:34:06Z
user: login: wmalgadey
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-11T07:46:51Z
user: login: wmalgadey
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-11T07:54:52Z

Review comments:
diff_hunk: @@ -5,7 +5,7 @@
 import responses
 import unittest
 
-from PyTado.const import CLIENT_ID, CLIENT_SECRET
+from PyTado.const import CLIENT_ID, CLIENT_ID_DEVICE, CLIENT_SECRET
path: tests/test_http.py
user: login: inverse
body: Should we clean up `CLIENT_ID` and `CLIENT_SECRET` from const as part of this refactor since they are not needed anymore?
created_at: 2025-02-19T22:29:32Z
author_association: NONE
diff_hunk: @@ -145,20 +144,36 @@ def __init__(
         else:
             _LOGGER.setLevel(logging.WARNING)
 
-        self._refresh_at = datetime.now() + timedelta(minutes=5)
+        self._refresh_at = datetime.now() + timedelta(minutes=10)
         self._session = http_session or requests.Session()
         self._session.hooks["response"].append(self._log_response)
         self._headers = {"Referer": "https://app.tado.com/"}
-        self._username = username
-        self._password = password
-        self._id, self._token_refresh = self._login()
+        self._user_code = None
+        self._device_verification_url = None
+        self._device_activation_status = "NOT_STARTED"
path: PyTado/http.py
user: login: inverse
body: Could be represented as an enum with all statuses
created_at: 2025-02-19T23:06:33Z
author_association: NONE
diff_hunk: @@ -330,21 +338,76 @@ def _login(self) -> tuple[int, str]:
                 },
             )
         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 TadoException(e) from e
 
         if response.status_code != 200:
             raise TadoException(
-                f"Login failed for unknown reason with status code {response.status_code}"
+                f"Login failed. Status code: {response.status_code} and reason: {response.reason}"
             )
 
-        refresh_token = self._set_oauth_header(response.json())
-        id_ = self._get_id()
+        self._device_flow_data = response.json()
+        _LOGGER.debug("Device flow response: %s", self._device_flow_data)
+
+        user_code = urlencode({"user_code": self._device_flow_data["user_code"]})
+        visit_url = f"{self._device_flow_data['verification_uri']}?{user_code}"
+        self._user_code = self._device_flow_data["user_code"]
+        self._device_verification_url = visit_url
+
+        _LOGGER.info("Please visit the following URL: %s", visit_url)
+
+        expires_in_seconds = self._device_flow_data["expires_in"]
+        self._expires_at = datetime.now() + timedelta(seconds=expires_in_seconds)
+
+        _LOGGER.info(
+            "Waiting for user to authorize the device. Expires at %s",
+            self._expires_at.strftime("%Y-%m-%d %H:%M:%S"))
+        self._device_activation_status = "PENDING"
+
+    def device_activation(self) -> None:
+        """Activate the device and get the refresh token"""
+        if self._expires_at is None or self._device_flow_data is None:
+            raise TadoException("User has not yet authorized the device")
 
-        return id_, refresh_token
+        while True:
+            if datetime.timestamp(datetime.now()) > datetime.timestamp(self._expires_at):
+                raise TadoException("User took too long to enter key")
+
+            # Await the desired interval, before polling the API again
+            time.sleep(self._device_flow_data["interval"])
+            try:
+                token_response = self._session.request(
+                    method="post",
+                    url="https://login.tado.com/oauth2/token",
+                    params={
+                        "client_id": CLIENT_ID_DEVICE,
+                        "device_code": self._device_flow_data["device_code"],
+                        "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
+                    }
+                )
+            except requests.exceptions.ConnectionError as e:
+                raise TadoException(e) from e
+
+            if token_response.status_code != 200:
+                # The user has not yet authorized the device, let's continue
+                if token_response.status_code == 400 and token_response.json()[
+                        "error"] == "authorization_pending":
+                    _LOGGER.info(
+                        "Authorization pending, waiting for user to authorize. Continue polling.")
+                    continue
+
+                raise TadoException(
+                    f"Login failed. Reason: {token_response.reason}"
+                )
+
+            if token_response.status_code == 200:
+                self._set_oauth_header(token_response.json())
path: PyTado/http.py
user: login: inverse
body: Given that user/pass are no longer configuration how should we persist the refresh token? e.g. when used in HA we will need to persist this in storage each time we refresh the token since its not static.
created_at: 2025-02-20T00:00:39Z
author_association: NONE
diff_hunk: @@ -16,20 +16,24 @@ class TadoZoneTestCase(unittest.TestCase):
     def setUp(self) -> None:
         super().setUp()
         login_patch = mock.patch(
-            "PyTado.http.Http._login", return_value=(1, "foo")
+            "PyTado.http.Http._login_device_flow", return_value=(1, "foo")
         )
+        device_activation_patch = mock.patch("PyTado.http.Http.device_activation")
         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()
+        device_activation_patch.start()
         is_x_line_patch.start()
         get_me_patch.start()
         self.addCleanup(login_patch.stop)
+        self.addCleanup(device_activation_patch.stop)
         self.addCleanup(is_x_line_patch.stop)
         self.addCleanup(get_me_patch.stop)
 
-        self.http = Http("my@username.com", "mypassword")
+        self.http = Http()
+        self.http.device_activation()
path: tests/test_hops_zone.py
user: login: Moritz-Schmidt
body: Since `device_activation()` is patched, the `Http._check_x_line_generation` method is never called, therefore `Http._x_api` is not set to `True`. This is why the unittests for TadoX are failing.

The easiest fix is to add `self.http._x_api = True` as a new line here.

(`test_set_window_open` and `test_reset_window_open` are still failing after the fix but for a different reason.)
created_at: 2025-02-20T09:19:31Z
author_association: NONE
diff_hunk: @@ -330,21 +338,76 @@ def _login(self) -> tuple[int, str]:
                 },
             )
         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 TadoException(e) from e
 
         if response.status_code != 200:
             raise TadoException(
-                f"Login failed for unknown reason with status code {response.status_code}"
+                f"Login failed. Status code: {response.status_code} and reason: {response.reason}"
             )
 
-        refresh_token = self._set_oauth_header(response.json())
-        id_ = self._get_id()
+        self._device_flow_data = response.json()
+        _LOGGER.debug("Device flow response: %s", self._device_flow_data)
+
+        user_code = urlencode({"user_code": self._device_flow_data["user_code"]})
+        visit_url = f"{self._device_flow_data['verification_uri']}?{user_code}"
+        self._user_code = self._device_flow_data["user_code"]
+        self._device_verification_url = visit_url
+
+        _LOGGER.info("Please visit the following URL: %s", visit_url)
+
+        expires_in_seconds = self._device_flow_data["expires_in"]
+        self._expires_at = datetime.now() + timedelta(seconds=expires_in_seconds)
+
+        _LOGGER.info(
+            "Waiting for user to authorize the device. Expires at %s",
+            self._expires_at.strftime("%Y-%m-%d %H:%M:%S"))
+        self._device_activation_status = "PENDING"
+
+    def device_activation(self) -> None:
+        """Activate the device and get the refresh token"""
+        if self._expires_at is None or self._device_flow_data is None:
+            raise TadoException("User has not yet authorized the device")
 
-        return id_, refresh_token
+        while True:
+            if datetime.timestamp(datetime.now()) > datetime.timestamp(self._expires_at):
+                raise TadoException("User took too long to enter key")
+
+            # Await the desired interval, before polling the API again
+            time.sleep(self._device_flow_data["interval"])
+            try:
+                token_response = self._session.request(
+                    method="post",
+                    url="https://login.tado.com/oauth2/token",
+                    params={
+                        "client_id": CLIENT_ID_DEVICE,
+                        "device_code": self._device_flow_data["device_code"],
+                        "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
+                    }
+                )
+            except requests.exceptions.ConnectionError as e:
+                raise TadoException(e) from e
+
+            if token_response.status_code != 200:
+                # The user has not yet authorized the device, let's continue
+                if token_response.status_code == 400 and token_response.json()[
+                        "error"] == "authorization_pending":
+                    _LOGGER.info(
+                        "Authorization pending, waiting for user to authorize. Continue polling.")
+                    continue
+
+                raise TadoException(
+                    f"Login failed. Reason: {token_response.reason}"
+                )
+
+            if token_response.status_code == 200:
+                self._set_oauth_header(token_response.json())
path: PyTado/http.py
user: login: Moritz-Schmidt
body: After some research I just found that HA has built-in support for OAuth2 for integrations, maybe this could be used here: https://developers.home-assistant.io/docs/config_entries_config_flow_handler#configuration-via-oauth2

I'll experiment a bit with it in a HA dev environment and will let you know if/how it works
created_at: 2025-02-20T09:38:44Z
author_association: NONE
diff_hunk: @@ -330,21 +338,76 @@ def _login(self) -> tuple[int, str]:
                 },
             )
         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 TadoException(e) from e
 
         if response.status_code != 200:
             raise TadoException(
-                f"Login failed for unknown reason with status code {response.status_code}"
+                f"Login failed. Status code: {response.status_code} and reason: {response.reason}"
             )
 
-        refresh_token = self._set_oauth_header(response.json())
-        id_ = self._get_id()
+        self._device_flow_data = response.json()
+        _LOGGER.debug("Device flow response: %s", self._device_flow_data)
+
+        user_code = urlencode({"user_code": self._device_flow_data["user_code"]})
+        visit_url = f"{self._device_flow_data['verification_uri']}?{user_code}"
+        self._user_code = self._device_flow_data["user_code"]
+        self._device_verification_url = visit_url
+
+        _LOGGER.info("Please visit the following URL: %s", visit_url)
+
+        expires_in_seconds = self._device_flow_data["expires_in"]
+        self._expires_at = datetime.now() + timedelta(seconds=expires_in_seconds)
+
+        _LOGGER.info(
+            "Waiting for user to authorize the device. Expires at %s",
+            self._expires_at.strftime("%Y-%m-%d %H:%M:%S"))
+        self._device_activation_status = "PENDING"
+
+    def device_activation(self) -> None:
+        """Activate the device and get the refresh token"""
+        if self._expires_at is None or self._device_flow_data is None:
+            raise TadoException("User has not yet authorized the device")
 
-        return id_, refresh_token
+        while True:
+            if datetime.timestamp(datetime.now()) > datetime.timestamp(self._expires_at):
+                raise TadoException("User took too long to enter key")
+
+            # Await the desired interval, before polling the API again
+            time.sleep(self._device_flow_data["interval"])
+            try:
+                token_response = self._session.request(
+                    method="post",
+                    url="https://login.tado.com/oauth2/token",
+                    params={
+                        "client_id": CLIENT_ID_DEVICE,
+                        "device_code": self._device_flow_data["device_code"],
+                        "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
+                    }
+                )
+            except requests.exceptions.ConnectionError as e:
+                raise TadoException(e) from e
+
+            if token_response.status_code != 200:
+                # The user has not yet authorized the device, let's continue
+                if token_response.status_code == 400 and token_response.json()[
+                        "error"] == "authorization_pending":
+                    _LOGGER.info(
+                        "Authorization pending, waiting for user to authorize. Continue polling.")
+                    continue
+
+                raise TadoException(
+                    f"Login failed. Reason: {token_response.reason}"
+                )
+
+            if token_response.status_code == 200:
+                self._set_oauth_header(token_response.json())
path: PyTado/http.py
user: login: erwindouna
body: It doesn't support the devixe flow. So we need to built it in PyTado.
created_at: 2025-02-20T11:13:56Z
author_association: COLLABORATOR
diff_hunk: @@ -16,20 +16,24 @@ class TadoZoneTestCase(unittest.TestCase):
     def setUp(self) -> None:
         super().setUp()
         login_patch = mock.patch(
-            "PyTado.http.Http._login", return_value=(1, "foo")
+            "PyTado.http.Http._login_device_flow", return_value=(1, "foo")
         )
+        device_activation_patch = mock.patch("PyTado.http.Http.device_activation")
         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()
+        device_activation_patch.start()
         is_x_line_patch.start()
         get_me_patch.start()
         self.addCleanup(login_patch.stop)
+        self.addCleanup(device_activation_patch.stop)
         self.addCleanup(is_x_line_patch.stop)
         self.addCleanup(get_me_patch.stop)
 
-        self.http = Http("my@username.com", "mypassword")
+        self.http = Http()
+        self.http.device_activation()
path: tests/test_hops_zone.py
user: login: wmalgadey
body: please try to rebase the branch. I did merge some fixes for tests in master!
created_at: 2025-02-20T14:09:58Z
author_association: OWNER
diff_hunk: @@ -16,20 +16,24 @@ class TadoZoneTestCase(unittest.TestCase):
     def setUp(self) -> None:
         super().setUp()
         login_patch = mock.patch(
-            "PyTado.http.Http._login", return_value=(1, "foo")
+            "PyTado.http.Http._login_device_flow", return_value=(1, "foo")
         )
+        device_activation_patch = mock.patch("PyTado.http.Http.device_activation")
         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()
+        device_activation_patch.start()
         is_x_line_patch.start()
         get_me_patch.start()
         self.addCleanup(login_patch.stop)
+        self.addCleanup(device_activation_patch.stop)
         self.addCleanup(is_x_line_patch.stop)
         self.addCleanup(get_me_patch.stop)
 
-        self.http = Http("my@username.com", "mypassword")
+        self.http = Http()
+        self.http.device_activation()
path: tests/test_hops_zone.py
user: login: erwindouna
body: > Since `device_activation()` is patched, the `Http._check_x_line_generation` method is never called, therefore `Http._x_api` is not set to `True`. This is why the unittests for TadoX are failing.
> 
> The easiest fix is to add `self.http._x_api = True` as a new line here.
> 
> (`test_set_window_open` and `test_reset_window_open` are still failing after the fix but for a different reason.)

Thanks! I've been staring at it too long, that I overlooked on it.
created_at: 2025-02-22T08:33:39Z
author_association: COLLABORATOR
diff_hunk: @@ -16,20 +16,24 @@ class TadoZoneTestCase(unittest.TestCase):
     def setUp(self) -> None:
         super().setUp()
         login_patch = mock.patch(
-            "PyTado.http.Http._login", return_value=(1, "foo")
+            "PyTado.http.Http._login_device_flow", return_value=(1, "foo")
         )
+        device_activation_patch = mock.patch("PyTado.http.Http.device_activation")
         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()
+        device_activation_patch.start()
         is_x_line_patch.start()
         get_me_patch.start()
         self.addCleanup(login_patch.stop)
+        self.addCleanup(device_activation_patch.stop)
         self.addCleanup(is_x_line_patch.stop)
         self.addCleanup(get_me_patch.stop)
 
-        self.http = Http("my@username.com", "mypassword")
+        self.http = Http()
+        self.http.device_activation()
path: tests/test_hops_zone.py
user: login: erwindouna
body: > please try to rebase the branch. I did merge some fixes for tests in master!

Is the pydantic upgrade not interfering with this update?
created_at: 2025-02-22T08:39:15Z
author_association: COLLABORATOR
diff_hunk: @@ -16,20 +16,24 @@ class TadoZoneTestCase(unittest.TestCase):
     def setUp(self) -> None:
         super().setUp()
         login_patch = mock.patch(
-            "PyTado.http.Http._login", return_value=(1, "foo")
+            "PyTado.http.Http._login_device_flow", return_value=(1, "foo")
         )
+        device_activation_patch = mock.patch("PyTado.http.Http.device_activation")
         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()
+        device_activation_patch.start()
         is_x_line_patch.start()
         get_me_patch.start()
         self.addCleanup(login_patch.stop)
+        self.addCleanup(device_activation_patch.stop)
         self.addCleanup(is_x_line_patch.stop)
         self.addCleanup(get_me_patch.stop)
 
-        self.http = Http("my@username.com", "mypassword")
+        self.http = Http()
+        self.http.device_activation()
path: tests/test_hops_zone.py
user: login: wmalgadey
body: 
The TadoX refactoring was merged to the pytado-v1.0 branch, not to the master-branch. Master does indeed contain some changes for testing.

created_at: 2025-02-23T18:21:41Z
author_association: OWNER
diff_hunk: @@ -16,20 +16,24 @@ class TadoZoneTestCase(unittest.TestCase):
     def setUp(self) -> None:
         super().setUp()
         login_patch = mock.patch(
-            "PyTado.http.Http._login", return_value=(1, "foo")
+            "PyTado.http.Http._login_device_flow", return_value=(1, "foo")
         )
+        device_activation_patch = mock.patch("PyTado.http.Http.device_activation")
         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()
+        device_activation_patch.start()
         is_x_line_patch.start()
         get_me_patch.start()
         self.addCleanup(login_patch.stop)
+        self.addCleanup(device_activation_patch.stop)
         self.addCleanup(is_x_line_patch.stop)
         self.addCleanup(get_me_patch.stop)
 
-        self.http = Http("my@username.com", "mypassword")
+        self.http = Http()
+        self.http.device_activation()
path: tests/test_hops_zone.py
user: login: erwindouna
body: Great, then I will work on completing this PR this week. Then we can fix this within HA asap.
created_at: 2025-02-24T09:38:32Z
author_association: COLLABORATOR
diff_hunk: @@ -145,20 +150,36 @@ def __init__(
         else:
             _LOGGER.setLevel(logging.WARNING)
 
-        self._refresh_at = datetime.now() + timedelta(minutes=5)
+        self._refresh_at = datetime.now() + timedelta(minutes=10)
         self._session = http_session or requests.Session()
         self._session.hooks["response"].append(self._log_response)
         self._headers = {"Referer": "https://app.tado.com/"}
-        self._username = username
-        self._password = password
-        self._id, self._token_refresh = self._login()
+        self._user_code = None
+        self._device_verification_url = None
+        self._device_activation_status = DeviceActivationStatus.NOT_STARTED
+        self._expires_at = None
 
-        self._x_api = self._check_x_line_generation()
+        self._login_device_flow()
+        self._id = None
+        self._token_refresh = None
+        self._x_api = False
path: PyTado/http.py
user: login: wmalgadey
body: Shouldn't we "invalidate" this, until it is truly false or true?
created_at: 2025-02-26T19:59:20Z
author_association: OWNER
diff_hunk: @@ -16,20 +16,25 @@ class TadoZoneTestCase(unittest.TestCase):
     def setUp(self) -> None:
         super().setUp()
         login_patch = mock.patch(
-            "PyTado.http.Http._login", return_value=(1, "foo")
+            "PyTado.http.Http._login_device_flow", return_value=(1, "foo")
path: tests/test_hops_zone.py
user: login: wmalgadey
body: the _login_device_flow does not return anything, doesn't it?
created_at: 2025-02-26T20:07:04Z
author_association: OWNER
diff_hunk: @@ -15,7 +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_device_flow", return_value=(1, "foo"))
path: tests/test_my_tado.py
user: login: wmalgadey
body: as noted before `_login_device_flow` doesn't return values
created_at: 2025-02-26T20:07:42Z
author_association: OWNER
diff_hunk: @@ -15,7 +15,9 @@ class TadoZoneTestCase(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_device_flow", return_value=(1, "foo")
path: tests/test_my_zone.py
user: login: wmalgadey
body: as noted before `_login_device_flow` doesn't return values
created_at: 2025-02-26T20:07:54Z
author_association: OWNER
diff_hunk: @@ -16,7 +16,7 @@ class TestTadoInterface(unittest.TestCase):
     @mock.patch("PyTado.interface.api.hops_tado.TadoX.get_me")
     def test_interface_with_tado_api(self, mock_hops_get_me, mock_my_get_me):
         login_patch = mock.patch(
-            "PyTado.http.Http._login", return_value=(1, "foo")
+            "PyTado.http.Http._login_device_flow", return_value=(1, "foo")
path: tests/test_tado_interface.py
user: login: wmalgadey
body: as noted before `_login_device_flow` doesn't return values
created_at: 2025-02-26T20:08:01Z
author_association: OWNER
diff_hunk: @@ -37,7 +47,7 @@ def test_interface_with_tado_api(self, mock_hops_get_me, mock_my_get_me):
 
     def test_interface_with_tadox_api(self):
         login_patch = mock.patch(
-            "PyTado.http.Http._login", return_value=(1, "foo")
+            "PyTado.http.Http._login_device_flow", return_value=(1, "foo")
path: tests/test_tado_interface.py
user: login: wmalgadey
body: as noted before `_login_device_flow` doesn't return values
created_at: 2025-02-26T20:08:50Z
author_association: OWNER
diff_hunk: @@ -59,7 +69,7 @@ def test_interface_with_tadox_api(self):
 
     def test_error_handling_on_api_calls(self):
         login_patch = mock.patch(
-            "PyTado.http.Http._login", return_value=(1, "foo")
+            "PyTado.http.Http._login_device_flow", return_value=(1, "foo")
path: tests/test_tado_interface.py
user: login: wmalgadey
body: as noted before `_login_device_flow` doesn't return values
created_at: 2025-02-26T20:08:55Z
author_association: OWNER
diff_hunk: @@ -145,20 +150,36 @@ def __init__(
         else:
             _LOGGER.setLevel(logging.WARNING)
 
-        self._refresh_at = datetime.now() + timedelta(minutes=5)
+        self._refresh_at = datetime.now() + timedelta(minutes=10)
         self._session = http_session or requests.Session()
         self._session.hooks["response"].append(self._log_response)
         self._headers = {"Referer": "https://app.tado.com/"}
-        self._username = username
-        self._password = password
-        self._id, self._token_refresh = self._login()
+        self._user_code = None
+        self._device_verification_url = None
+        self._device_activation_status = DeviceActivationStatus.NOT_STARTED
+        self._expires_at = None
 
-        self._x_api = self._check_x_line_generation()
+        self._login_device_flow()
+        self._id = None
+        self._token_refresh = None
+        self._x_api = False
path: PyTado/http.py
user: login: erwindouna
body: Invalidate? False is normally what I perceive as the initial value of a Boolean. Or am I overlooking your point? :)
created_at: 2025-02-27T08:27:57Z
author_association: COLLABORATOR
diff_hunk: @@ -145,20 +150,36 @@ def __init__(
         else:
             _LOGGER.setLevel(logging.WARNING)
 
-        self._refresh_at = datetime.now() + timedelta(minutes=5)
+        self._refresh_at = datetime.now() + timedelta(minutes=10)
         self._session = http_session or requests.Session()
         self._session.hooks["response"].append(self._log_response)
         self._headers = {"Referer": "https://app.tado.com/"}
-        self._username = username
-        self._password = password
-        self._id, self._token_refresh = self._login()
+        self._user_code = None
+        self._device_verification_url = None
+        self._device_activation_status = DeviceActivationStatus.NOT_STARTED
+        self._expires_at = None
 
-        self._x_api = self._check_x_line_generation()
+        self._login_device_flow()
+        self._id = None
+        self._token_refresh = None
+        self._x_api = False
path: PyTado/http.py
user: login: wmalgadey
body: The problem with `_x_api` is, that it is an informationen about the used API. If it is false, it is the old API, if it is true, it uses the new API. But... we can't tell, what API is used, until we did the Authentication. So actually, the `_x_api` is indetermined until we did the login!
created_at: 2025-02-27T09:00:10Z
author_association: OWNER
diff_hunk: @@ -145,20 +150,36 @@ def __init__(
         else:
             _LOGGER.setLevel(logging.WARNING)
 
-        self._refresh_at = datetime.now() + timedelta(minutes=5)
+        self._refresh_at = datetime.now() + timedelta(minutes=10)
         self._session = http_session or requests.Session()
         self._session.hooks["response"].append(self._log_response)
         self._headers = {"Referer": "https://app.tado.com/"}
-        self._username = username
-        self._password = password
-        self._id, self._token_refresh = self._login()
+        self._user_code = None
+        self._device_verification_url = None
+        self._device_activation_status = DeviceActivationStatus.NOT_STARTED
+        self._expires_at = None
 
-        self._x_api = self._check_x_line_generation()
+        self._login_device_flow()
+        self._id = None
+        self._token_refresh = None
+        self._x_api = False
path: PyTado/http.py
user: login: erwindouna
body: I see your point. We can put it on None for this reason. Thanks!
created_at: 2025-02-28T07:02:14Z
author_association: COLLABORATOR
diff_hunk: @@ -145,20 +150,36 @@ def __init__(
         else:
             _LOGGER.setLevel(logging.WARNING)
 
-        self._refresh_at = datetime.now() + timedelta(minutes=5)
+        self._refresh_at = datetime.now() + timedelta(minutes=10)
         self._session = http_session or requests.Session()
         self._session.hooks["response"].append(self._log_response)
         self._headers = {"Referer": "https://app.tado.com/"}
-        self._username = username
-        self._password = password
-        self._id, self._token_refresh = self._login()
+        self._user_code = None
+        self._device_verification_url = None
+        self._device_activation_status = DeviceActivationStatus.NOT_STARTED
+        self._expires_at = None
 
-        self._x_api = self._check_x_line_generation()
+        self._login_device_flow()
+        self._id = None
+        self._token_refresh = None
+        self._x_api = False
path: PyTado/http.py
user: login: Moritz-Schmidt
body: We could use `@cached_property` from functools for this (like with [`_auto_geofencing_supported` in the 1.0 branch](https://github.com/wmalgadey/PyTado/blob/pytado-v1.0/PyTado/interface/api/base_tado.py#L113)), this would mean that the function checking for X-Api is only run once when the property is first accessed, then the result will be cached. However we'll have to make sure, that it is only accessed after successful login (or throw an error if not logged in yet)
created_at: 2025-02-28T08:53:41Z
author_association: NONE
diff_hunk: @@ -62,6 +58,18 @@ def __getattr__(self, name):
     # region Deprecated Methods
     # pylint: disable=invalid-name
 
+    def device_verification_url(self) -> str | None:
+        """Returns the URL for device verification."""
+        return self._http.device_verification_url
+
+    def device_activation_status(self) -> str | None:
+        """Returns the status of the device activation."""
+        return self._http.device_activation_status
+
+    def device_activation(self) -> None:
+        """Activates the device."""
+        return self._http.device_activation()
+
path: PyTado/interface/interface.py
user: login: Moritz-Schmidt
body: I think these methods should be added to the `Tado` in `my_tado.py` and `TadoX` in `hops_tado.py` classes instead of here where most methods are going to be deprecated
created_at: 2025-02-28T09:00:25Z
author_association: NONE
path: .vscode/launch.json
user: login: Moritz-Schmidt
body: Dev-Stuff should probably not be part of this PR (however I'm in favour of having a good launch.json for development)
created_at: 2025-02-28T09:02:47Z
author_association: NONE
path: .vscode/settings.json
user: login: Moritz-Schmidt
body: Same here as for the launch.json file, also there is a Path specific to your System here.
created_at: 2025-02-28T09:04:30Z
author_association: NONE
path: .vscode/launch.json
user: login: wmalgadey
body: @erwindouna just rebase, I did add those changes master (see #164)
created_at: 2025-03-01T12:33:58Z
author_association: OWNER
path: .vscode/settings.json
user: login: wmalgadey
body: @erwindouna just rebase, I did add those changes master (see #164)
created_at: 2025-03-01T12:34:06Z
author_association: OWNER
diff_hunk: @@ -145,20 +150,36 @@ def __init__(
         else:
             _LOGGER.setLevel(logging.WARNING)
 
-        self._refresh_at = datetime.now() + timedelta(minutes=5)
+        self._refresh_at = datetime.now() + timedelta(minutes=10)
         self._session = http_session or requests.Session()
         self._session.hooks["response"].append(self._log_response)
         self._headers = {"Referer": "https://app.tado.com/"}
-        self._username = username
-        self._password = password
-        self._id, self._token_refresh = self._login()
+        self._user_code = None
+        self._device_verification_url = None
+        self._device_activation_status = DeviceActivationStatus.NOT_STARTED
+        self._expires_at = None
 
-        self._x_api = self._check_x_line_generation()
+        self._login_device_flow()
+        self._id = None
+        self._token_refresh = None
+        self._x_api = False
path: PyTado/http.py
user: login: wmalgadey
body: Let's stick for now with the `: None | bool` mixing
created_at: 2025-03-11T07:46:50Z
author_association: OWNER
diff_hunk: @@ -62,6 +58,18 @@ def __getattr__(self, name):
     # region Deprecated Methods
     # pylint: disable=invalid-name
 
+    def device_verification_url(self) -> str | None:
+        """Returns the URL for device verification."""
+        return self._http.device_verification_url
+
+    def device_activation_status(self) -> str | None:
+        """Returns the status of the device activation."""
+        return self._http.device_activation_status
+
+    def device_activation(self) -> None:
+        """Activates the device."""
+        return self._http.device_activation()
+
path: PyTado/interface/interface.py
user: login: wmalgadey
body: I think that this is not possible, because with new device flow, we have to wait for device authentication, before we can tell which api to use.
created_at: 2025-03-11T07:54:51Z
author_association: OWNER

PR comments:
user: login: erwindouna
created_at: 2025-02-18T19:29:15Z
body: @wmalgadey once this gets tackled, we need to build a separate release. Tado will stop support of the password flow as of the 15th of March. Let me know if you have questions, comments or other feedback! :)
author_association: COLLABORATOR
user: login: erwindouna
created_at: 2025-02-18T19:31:32Z
body: @Moritz-Schmidt can you please help around with the TadoX unit tests? They are failing and I seem to be overlooking why. Perhaps it's the sequence of the tests, but I might need some feedback here. :)
author_association: COLLABORATOR
user: login: wmalgadey
created_at: 2025-02-18T19:44:27Z
body: Sure, I will look into it on wednesday. Have been on a convetion today and couldn‘t get on my computer :)
author_association: OWNER
user: login: Moritz-Schmidt
created_at: 2025-02-18T19:59:46Z
body: I have an Exam tomorrow but after that I should have time to look into it :)

Thank you very much @erwindouna for implementing the new auth flow!
author_association: NONE
user: login: paulvha
created_at: 2025-03-07T14:28:39Z
body: I had updated in PR155, but looks a better place. 

I have your PR 157 applied on downloaded github code and I am testing my application which works with the current PyTado. I removed the username and passwd

I got 2 errors when first login in, but found in the meantime in the READ.ME that parts of the new process needs to be handled on the application side. Login is now working. Attached the function I used in my application to handle the new login. As I run on Ubuntu I start the browser directly, but that can be removed

[newlog.zip](https://github.com/user-attachments/files/19127423/newlog.zip)

**BUG:**
device_activation_status() returns 'DeviceActivationStatus.PENDING' instead of "PENDING"

**SOLUTION**
DeviceActivationStatus is incorrect defined as (enum.Enum) instead of (enum.StrEnum).

Discovered why it can't find my Tado X rooms

**BUG:**
In the init section for Tado (interface.py) and TadoX(hops_tado.py) it calls for if '_self.http.is_x_line' to check whether I have Tado X. This is determined by a function _ check_x_line_generation().
In the _old-setup_ this function was called as part on init Http (http.py) and thus correctly reflecting the situation.
In the _new-setup_ this is called after later after validation in device_activation(). As such '_self.http.is_x_line' is not returning the right information at that moment for init() Tado and TadoX

**Temporarily workaround for Tado-X**
In init Http (http.py) set : self._x_api = True

**SOLUTION**
Don't know

**_my 2 cents.._**

1. I am missing the opportunity to save the refresh code in a local file. That way a users only have to login ONCE. Easier when developing and making changes to an application and restart, easier when you have multiple applications.

2. There is extra work for the users to change each and every application to make a new login. It is a choose, but it makes the switch over more difficult than it needs to be in my mind. The approach that Mike Marino provided, and I had applied , is making it much more easier I found.

Apart from that, I still like this PyTado version and thanks for all the work !

Groeten uit Hoofddorp
author_association: NONE
user: login: wmalgadey
created_at: 2025-03-07T16:40:01Z
body: > In the init section for Tado (interface.py) and TadoX(hops_tado.py) it calls for if '_self.http.is_x_line' to check whether I have Tado X. This is determined by a function _ check_x_line_generation().

we found this bug too, but because we actually didn't want to release a 0.19.x with the current changes from the master-branch, we created a `support/0.18` branch, where we migrated all the changes from here to.

Maybe, could you test the changes from here https://github.com/wmalgadey/PyTado/tree/support/0.18?
author_association: OWNER
user: login: paulvha
created_at: 2025-03-07T18:48:16Z
body: I have now tried the 0.18 version and testing on Tado X works good so far, but I still had to change the DeviceActivationStatus to (enum.StrEnum) to get a return of  "PENDING"  from device_activation_status()

More testing happening.
author_association: NONE