Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions roborock/web_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
53 changes: 53 additions & 0 deletions tests/test_web_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
Loading