From 37edfb6e082dcd74a481ec82aafd72a9ba8b90e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E4=B9=98=E5=A6=8D=20=28Xiaoyaner=29?= Date: Tue, 15 Sep 2026 20:11:40 +0800 Subject: [PATCH] fix: fetch the latest user agreement version instead of hardcoding it `code_login_v4` sent a hardcoded `majorVersion: 14`/`minorVersion: 0` with the login request. The server rejects a stale agreement version with code 3006, which surfaces as `RoborockInvalidUserAgreement` ("User agreement must be accepted again - or you are attempting to use the Mi Home app account"). The current version is not 14 on any server today, and it differs per server and per country, so it cannot be hardcoded: | server | latest majorVersion.minorVersion | | ------ | -------------------------------- | | cniot | 19.0 | | usiot | 16.0 (country=US) | | euiot | 15.1 | | ruiot | 15.0 | Because the value is stale server-side rather than account-side, accepting the agreement again in the app cannot clear it, which is why the usual revoke-and-re-accept workaround does not help. Query `/api/v3/app/agreement/latest` (the endpoint already named in the removed comment) and send the returned version, falling back to the previous constants so a failed lookup never blocks login. `minorVersion` is taken from the response too, since euiot currently reports 15.1. --- roborock/web_api.py | 37 ++++++++++++++++++++++++++---- tests/test_web_api.py | 53 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/roborock/web_api.py b/roborock/web_api.py index 802c10212..88ed2b1c6 100644 --- a/roborock/web_api.py +++ b/roborock/web_api.py @@ -38,6 +38,10 @@ "https://ruiot.roborock.com", ] +# Fallback user agreement version, used only if the latest version cannot be fetched. +DEFAULT_AGREEMENT_MAJOR_VERSION = 14 +DEFAULT_AGREEMENT_MINOR_VERSION = 0 + @dataclass class IotLoginInfo: @@ -292,6 +296,34 @@ async def _sign_key_v3(self, s: str) -> str: return code_response["data"]["k"] + async def _get_agreement_version(self, country: str) -> dict[str, int]: + """Get the latest user agreement version for the given country. + + The login endpoint rejects a stale agreement version with code 3006. The + current version differs per server and per country, so it must be looked + up rather than hardcoded. Falls back to the previously hardcoded values + if the lookup fails so that login is never blocked by this request. + """ + try: + base_url = await self.base_url + agreement_request = PreparedRequest(base_url, self.session, {"header_clientlang": "en"}) + response = await agreement_request.request( + "get", + "/api/v3/app/agreement/latest", + params={"country": country}, + ) + if response is not None and response.get("code") == 200: + data = response.get("data") or {} + major = data.get("majorVersion") + minor = data.get("minorVersion") + if isinstance(major, int) and isinstance(minor, int): + _LOGGER.debug("Using user agreement version %s.%s for %s", major, minor, country) + return {"majorVersion": major, "minorVersion": minor} + _LOGGER.debug("Unexpected agreement version response: %s", response) + except RoborockException as err: + _LOGGER.debug("Could not fetch latest user agreement version: %s", err) + return {"majorVersion": DEFAULT_AGREEMENT_MAJOR_VERSION, "minorVersion": DEFAULT_AGREEMENT_MINOR_VERSION} + async def code_login_v4( self, code: int | str, country: str | None = None, country_code: int | None = None ) -> UserData: @@ -334,10 +366,7 @@ async def code_login_v4( "countryCode": country_code, "email": self._username, "code": code, - # Major and minor version are the user agreement version, we will need to see if this needs to be - # dynamic https://usiot.roborock.com/api/v3/app/agreement/latest?country=US - "majorVersion": 14, - "minorVersion": 0, + **await self._get_agreement_version(country), }, ) if login_response is None: diff --git a/tests/test_web_api.py b/tests/test_web_api.py index cb6d0cb34..a4e38ec77 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -9,6 +9,8 @@ from roborock import HomeData, HomeDataRoom, HomeDataScene, UserData from roborock.exceptions import RoborockAccountDoesNotExist, RoborockException, RoborockInvalidCredentials from roborock.web_api import ( + DEFAULT_AGREEMENT_MAJOR_VERSION, + DEFAULT_AGREEMENT_MINOR_VERSION, IotLoginInfo, PreparedRequest, RoborockApiClient, @@ -126,6 +128,57 @@ async def test_code_login_v4_flow(mock_rest) -> None: assert ud == UserData.from_dict(USER_DATA) +async def test_code_login_v4_uses_latest_agreement_version(mock_rest) -> None: + """Test that the agreement version sent on login is fetched, not hardcoded. + + The login endpoint rejects a stale agreement version with code 3006, and the + current version differs per server and country, so it cannot be hardcoded. + """ + mock_rest.get( + re.compile(r"https://.*iot\.roborock\.com/api/v3/app/agreement/latest.*"), + status=200, + payload={"code": 200, "data": {"majorVersion": 19, "minorVersion": 1}, "msg": "success"}, + ) + + api = RoborockApiClient(username="test_user@gmail.com") + await api.request_code_v4() + await api.code_login_v4(4123, "US", 1) + + login_calls = [ + (key, call) + for key, calls in mock_rest.requests.items() + for call in calls + if "api/v4/auth/email/login/code" in str(key[1]) + ] + assert login_calls, "expected a v4 login request" + data = login_calls[-1][1].kwargs["data"] + assert data["majorVersion"] == 19 + assert data["minorVersion"] == 1 + + +async def test_code_login_v4_agreement_version_fallback(mock_rest) -> None: + """Test that login still proceeds when the agreement version lookup fails.""" + mock_rest.get( + re.compile(r"https://.*iot\.roborock\.com/api/v3/app/agreement/latest.*"), + exception=aiohttp.ClientError("boom"), + ) + + api = RoborockApiClient(username="test_user@gmail.com") + await api.request_code_v4() + ud = await api.code_login_v4(4123, "US", 1) + assert ud == UserData.from_dict(USER_DATA) + + login_calls = [ + (key, call) + for key, calls in mock_rest.requests.items() + for call in calls + if "api/v4/auth/email/login/code" in str(key[1]) + ] + data = login_calls[-1][1].kwargs["data"] + assert data["majorVersion"] == DEFAULT_AGREEMENT_MAJOR_VERSION + assert data["minorVersion"] == DEFAULT_AGREEMENT_MINOR_VERSION + + async def test_code_login_v4_account_does_not_exist(mock_rest) -> None: """Test that response code 3039 raises RoborockAccountDoesNotExist.""" mock_rest.clear()