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: