From bd983179e9b0a221a4210c1d1f6bfb223d533e84 Mon Sep 17 00:00:00 2001
From: Pavel Turbin
Date: Sat, 27 Jun 2026 12:34:17 +0100
Subject: [PATCH] Mi Home cloud: avoid repeated 2FA prompts / 24h lockout for
accounts far from the cloud server
Problem
-------
The Mi Home cloud protocol (DreameVacuumMiHomeCloudProtocol) uses short
6-10s HTTP/MQTT timeouts. When the Xiaomi account's cloud region is
geographically far from the user (e.g. a CN-region account accessed from the
EU/UK), ordinary latency regularly exceeds those timeouts.
A timed-out request returns None, which is indistinguishable from a real
authentication failure, so check_login()/the request helpers report the session
as "not logged in" and trigger a full password re-login. That re-login needs
2FA; the emailed code can't be entered automatically, so it fails and retries
on the next poll. After a few cycles Xiaomi temporarily blocks 2FA (~24h) and
signs the account out everywhere - including the phone app.
So a slow network, not an actual credential problem, ends up locking the user
out of their own account.
Solution
--------
All changes are confined to DreameVacuumMiHomeCloudProtocol; the Dreame cloud
and local protocols are untouched.
1. Longer timeouts + retries. Mi Home HTTP timeouts raised 6/10s -> 120s and
request() retries with backoff. request() records _last_timeout
(response is None) so callers can tell a true network timeout apart from a
server (non-200) response.
2. Don't re-login on a timeout. check_login(), when validating the cached
session and getting no response, keeps the session on a network timeout
(returns True) and only treats an actual server rejection as an auth failure.
This stops transient latency from cascading into a 2FA storm.
3. Silent session refresh via passToken. New refresh_token() mirrors the Mi
Home app: serviceLogin with the stored passToken -> fresh ssecurity +
serviceToken, no password/2FA. login() now tries
check_login() -> refresh_token() -> full password+2FA login (last resort).
passToken is captured from serviceLogin and carried as an optional 5th field
in auth_key, so it survives restarts. Backward compatible: an existing
4-field auth_key still parses.
Result: accounts far from their cloud server no longer get repeated 2FA prompts
or 24h lockouts; the session refreshes silently, like the official app.
Co-Authored-By: Claude Opus 4.8
---
.../dreame_vacuum/dreame/protocol.py | 127 +++++++++++++++---
1 file changed, 109 insertions(+), 18 deletions(-)
diff --git a/custom_components/dreame_vacuum/dreame/protocol.py b/custom_components/dreame_vacuum/dreame/protocol.py
index 2959657b..e5b815f8 100644
--- a/custom_components/dreame_vacuum/dreame/protocol.py
+++ b/custom_components/dreame_vacuum/dreame/protocol.py
@@ -819,6 +819,7 @@ def __init__(
self._ssecurity = None
self._userId = None
self._service_token = None
+ self._pass_token = None
self._captcha_ick = None
self._captcha_code = None
self._logged_in = False
@@ -829,11 +830,13 @@ def __init__(
if self._auth_key:
data = self._auth_key.split(" ")
- if len(data) == 4:
+ if len(data) >= 4:
self._service_token = data[0]
self._ssecurity = data[1]
self._userId = data[2]
self._client_id = data[3]
+ if len(data) >= 5:
+ self._pass_token = data[4] # silent-refresh credential (rides along in auth_key)
self._useragent = f"Android-7.1.1-1.0.0-ONEPLUS A3010-136-{self._client_id} APP/xiaomi.smarthome APPV/62830"
self._locale = locale.getdefaultlocale()[0]
@@ -860,7 +863,7 @@ def _api_task(self):
self._thread = None
return
response = self._api_call(item[1], item[2], item[3])
- if not self.check_login(response):
+ if response is not None and not self.check_login(response):
self._logged_in = False
self._auth_failed = True
response = None
@@ -881,7 +884,7 @@ def _api_call(self, url, params, retry_count=2, timeout=None):
f"{self.get_api_url()}/{url}", {"data": json.dumps(params, separators=(",", ":"))}, retry_count, timeout
)
- if not self.check_login(response):
+ if response is not None and not self.check_login(response):
self._logged_in = False
self._auth_failed = True
response = None
@@ -916,6 +919,7 @@ def object_name(self) -> str:
return f"{str(self._uid)}/{str(self._did)}/0"
def check_login(self, response=None) -> bool:
+ self_check = response is None
try:
if response is None:
response = self.request(
@@ -942,6 +946,19 @@ def check_login(self, response=None) -> bool:
):
return False
return True
+ # No response object at all while validating our OWN cached session.
+ # Distinguish two cases via _last_timeout (set by request()):
+ # - TRUE network timeout (no server response): NOT an auth rejection -> keep the
+ # cached session, don't cascade into a full password re-login (avoids spurious 2FA).
+ # - Server responded non-200 (auth rejection -> request() returned None but not a
+ # timeout): treat as auth failure (return False) so login() proceeds to
+ # refresh_token()/full re-login.
+ if self_check:
+ if getattr(self, "_last_timeout", False):
+ _LOGGER.debug("check_login: network timeout — keeping cached session, skipping re-login")
+ return True
+ _LOGGER.debug("check_login: server rejected token (non-200) — treating as auth failure")
+ return False
except:
pass
return False
@@ -955,7 +972,7 @@ def login_step_1(self) -> bool:
"Content-Type": "application/x-www-form-urlencoded",
},
cookies={"deviceId": self._client_id},
- timeout=10,
+ timeout=120,
)
if response is not None:
if response.status_code == 200:
@@ -1003,7 +1020,7 @@ def login_step_2(self) -> bool:
data=data,
params=params,
cookies=cookies,
- timeout=10,
+ timeout=120,
)
if response is not None:
if response.status_code == 200:
@@ -1013,6 +1030,13 @@ def login_step_2(self) -> bool:
self._userId = data.get("userId", self._userId)
self._ssecurity = data.get("ssecurity", self._ssecurity)
self._location = location
+ try:
+ pass_token = data.get("passToken") or response.cookies.get("passToken")
+ if pass_token:
+ self._pass_token = pass_token
+ _LOGGER.debug("Captured passToken (len=%s) for future silent re-login", len(pass_token))
+ except Exception:
+ pass
return True
if "notificationUrl" in data:
@@ -1044,12 +1068,17 @@ def login_step_3(self) -> bool:
"User-Agent": self._useragent,
"Content-Type": "application/x-www-form-urlencoded",
},
- timeout=10,
+ timeout=120,
)
if response is not None:
if response.status_code == 200 and "serviceToken" in response.cookies:
self._service_token = response.cookies.get("serviceToken")
- self._auth_key = f"{self._service_token} {self._ssecurity} {self._userId} {self._client_id}"
+ pt = self._session.cookies.get("passToken")
+ if pt:
+ self._pass_token = pt
+ self._auth_key = f"{self._service_token} {self._ssecurity} {self._userId} {self._client_id}" + (
+ f" {self._pass_token}" if self._pass_token else ""
+ )
return True
else:
self._auth_failed = True
@@ -1057,6 +1086,53 @@ def login_step_3(self) -> bool:
pass
return False
+ def refresh_token(self) -> bool:
+ """Silently refresh the session using a stored passToken (no password/2FA).
+
+ Mirrors what the Mi Home app does: GET serviceLogin with the passToken cookie ->
+ code:0 + fresh ssecurity + (rotated) passToken + STS location -> follow it for a
+ new serviceToken. Self-contained (own temp session) so a failure leaves the
+ caller's session intact for the full-login fallback. Validated working 2026-06-26.
+ """
+ if not self._pass_token:
+ return False
+ try:
+ s = requests.session()
+ s.cookies.set("sdkVersion", "3.8.6", domain="xiaomi.com")
+ s.cookies.set("deviceId", self._client_id, domain="xiaomi.com")
+ if self._userId:
+ s.cookies.set("userId", str(self._userId), domain="xiaomi.com")
+ s.cookies.set("passToken", self._pass_token, domain="xiaomi.com")
+ r = s.get(
+ "https://account.xiaomi.com/pass/serviceLogin?sid=xiaomiio&_json=true",
+ headers={"User-Agent": self._useragent, "Content-Type": "application/x-www-form-urlencoded"},
+ timeout=120,
+ )
+ if r is None or r.status_code != 200:
+ return False
+ data = self.to_json(r.text)
+ if data.get("code") != 0 or not data.get("location"):
+ _LOGGER.debug("refresh_token: passToken rejected (code=%s)", data.get("code"))
+ return False
+ user_id = data.get("userId", self._userId)
+ ssecurity = data.get("ssecurity", self._ssecurity)
+ location = data.get("location")
+ new_pt = data.get("passToken") or s.cookies.get("passToken") or self._pass_token
+ r2 = s.get(location, headers={"User-Agent": self._useragent}, timeout=120)
+ service_token = s.cookies.get("serviceToken")
+ if r2 is not None and r2.status_code == 200 and service_token:
+ self._userId = user_id
+ self._ssecurity = ssecurity
+ self._service_token = service_token
+ self._pass_token = new_pt
+ self._session = s
+ self._auth_key = f"{self._service_token} {self._ssecurity} {self._userId} {self._client_id} {self._pass_token}"
+ _LOGGER.debug("refresh_token: silent re-login via passToken OK")
+ return True
+ except Exception as ex: # noqa: BLE001
+ _LOGGER.debug("refresh_token error: %s", ex)
+ return False
+
def login(self) -> bool:
self.login_error = None
self.verification_dest = None
@@ -1067,8 +1143,10 @@ def login(self) -> bool:
self._session.cookies.set("deviceId", self._client_id, domain="mi.com")
self._session.cookies.set("deviceId", self._client_id, domain="xiaomi.com")
- logged_in = (self._ssecurity and self.check_login()) or (
- self.login_step_1() and self.login_step_2() and self.login_step_3()
+ logged_in = (
+ (self._ssecurity and self.check_login())
+ or self.refresh_token()
+ or (self.login_step_1() and self.login_step_2() and self.login_step_3())
)
if logged_in:
@@ -1076,6 +1154,12 @@ def login(self) -> bool:
self._auth_failed = False
self._fail_count = 0
self._connected = True
+ try:
+ pt = self._session.cookies.get("passToken")
+ if pt:
+ self._pass_token = pt
+ except Exception: # noqa: BLE001
+ pass
else:
self._ssecurity = None
@@ -1094,7 +1178,7 @@ def send_2fa_code(self, verification_url) -> bool:
response = self._session.get(
"https://account.xiaomi.com/identity/list",
params={"sid": "xiaomiio", "context": context, "_locale": str(self._locale)},
- timeout=10,
+ timeout=120,
)
if response and response.status_code == 200:
identity_session = response.cookies.get("identity_session")
@@ -1120,7 +1204,7 @@ def send_2fa_code(self, verification_url) -> bool:
"mask": "0",
"_locale": str(self._locale),
},
- timeout=10,
+ timeout=120,
)
if not verify_response or verify_response.status_code != 200:
return False
@@ -1159,7 +1243,7 @@ def send_2fa_code(self, verification_url) -> bool:
"_json": "true",
"ick": self._session.cookies.get("ick", ""),
},
- timeout=10,
+ timeout=120,
)
if not send_response or send_response.status_code != 200:
return False
@@ -1210,7 +1294,7 @@ def verify_code(self, code) -> bool:
"https://account.xiaomi.com/identity/list",
params={"sid": "xiaomiio", "context": context, "_locale": str(self._locale)},
headers=headers,
- timeout=10,
+ timeout=120,
)
if response is None:
_LOGGER.error(f"2FA failed: identity/list endpoint failed!")
@@ -1293,7 +1377,7 @@ def verify_code(self, code) -> bool:
params={"sid": "xiaomiio", "context": context, "_locale": str(self._locale)},
headers=headers,
allow_redirects=False,
- timeout=10,
+ timeout=120,
)
if response is None:
@@ -1384,7 +1468,7 @@ def get_file(self, url: str, retry_count: int = 4) -> Any:
retry_count = 0
while retries < retry_count + 1:
try:
- response = self._session.get(url, timeout=6)
+ response = self._session.get(url, timeout=120)
except Exception as ex:
response = None
_LOGGER.warning("Unable to get file at %s: %s", url, ex)
@@ -1616,7 +1700,7 @@ def set_batch_device_datas(self, props) -> Any:
return None
return api_response["result"]
- def request(self, url: str, params: Dict[str, str], retry_count=2, timeout=None) -> Any:
+ def request(self, url: str, params: Dict[str, str], retry_count=5, timeout=None) -> Any:
retries = 0
if not retry_count or retry_count < 0:
retry_count = 0
@@ -1645,14 +1729,21 @@ def request(self, url: str, params: Dict[str, str], retry_count=2, timeout=None)
while retries < retry_count + 1:
try:
response = self._session.post(
- url, headers=headers, cookies=cookies, data=fields, timeout=timeout if timeout else 6
+ url, headers=headers, cookies=cookies, data=fields, timeout=timeout if timeout else 120
)
break
except Exception as ex:
retries = retries + 1
response = None
if self._connected:
- _LOGGER.warning("Error while executing request: %s %s", url, str(ex))
+ _LOGGER.warning("Error while executing request (try %s/%s): %s %s", retries, retry_count + 1, url, str(ex))
+ if retries < retry_count + 1:
+ sleep(min(2 * retries, 10))
+
+ # Distinguish a true network timeout (no response at all) from a server
+ # response (incl. non-200 auth rejection). Used by check_login to decide
+ # whether to keep the cached session (timeout) or treat it as auth failure.
+ self._last_timeout = response is None
if response is not None:
if response.status_code == 200: