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()