Skip to content

Commit 1eebd29

Browse files
authored
feat: add v4 for code login (#486)
* feat: add v4 for code login * chore: add tests
1 parent efa2922 commit 1eebd29

File tree

3 files changed

+131
-0
lines changed

3 files changed

+131
-0
lines changed

roborock/web_api.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import logging
77
import math
88
import secrets
9+
import string
910
import time
1011

1112
import aiohttp
@@ -190,6 +191,113 @@ async def request_code(self) -> None:
190191
else:
191192
raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}")
192193

194+
async def request_code_v4(self) -> None:
195+
"""Request a code using the v4 endpoint."""
196+
try:
197+
self._login_limiter.try_acquire("login")
198+
except BucketFullException as ex:
199+
_LOGGER.info(ex.meta_info)
200+
raise RoborockRateLimit("Reached maximum requests for login. Please try again later.") from ex
201+
base_url = await self._get_base_url()
202+
header_clientid = self._get_header_client_id()
203+
code_request = PreparedRequest(
204+
base_url,
205+
self.session,
206+
{
207+
"header_clientid": header_clientid,
208+
"Content-Type": "application/x-www-form-urlencoded",
209+
"header_clientlang": "en",
210+
},
211+
)
212+
213+
code_response = await code_request.request(
214+
"post",
215+
"/api/v4/email/code/send",
216+
params={"email": self._username, "type": "login", "platform": ""},
217+
)
218+
if code_response is None:
219+
raise RoborockException("Failed to get a response from send email code")
220+
response_code = code_response.get("code")
221+
if response_code != 200:
222+
_LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response)
223+
if response_code == 2008:
224+
raise RoborockAccountDoesNotExist("Account does not exist - check your login and try again.")
225+
elif response_code == 9002:
226+
raise RoborockTooFrequentCodeRequests("You have attempted to request too many codes. Try again later")
227+
else:
228+
raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}")
229+
230+
async def sign_key_v3(self, s: str) -> str:
231+
"""Sign a randomly generated string."""
232+
base_url = await self._get_base_url()
233+
header_clientid = self._get_header_client_id()
234+
code_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
235+
236+
code_response = await code_request.request(
237+
"post",
238+
"/api/v3/key/sign",
239+
params={"s": s},
240+
)
241+
242+
if not code_response or "data" not in code_response or "k" not in code_response["data"]:
243+
raise RoborockException("Failed to get a response from sign key")
244+
response_code = code_response.get("code")
245+
246+
if response_code != 200:
247+
_LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response)
248+
raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}")
249+
250+
return code_response["data"]["k"]
251+
252+
async def code_login_v4(self, code: int | str, country: str, country_code: int) -> UserData:
253+
"""
254+
Login via code authentication.
255+
:param code: The code from the email.
256+
:param country: The two-character representation of the country, i.e. "US"
257+
:param country_code: the country phone number code i.e. 1 for US.
258+
"""
259+
base_url = await self._get_base_url()
260+
header_clientid = self._get_header_client_id()
261+
x_mercy_ks = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
262+
x_mercy_k = await self.sign_key_v3(x_mercy_ks)
263+
login_request = PreparedRequest(
264+
base_url,
265+
self.session,
266+
{"header_clientid": header_clientid, "x-mercy-ks": x_mercy_ks, "x-mercy-k": x_mercy_k},
267+
)
268+
login_response = await login_request.request(
269+
"post",
270+
"/api/v4/auth/email/login/code",
271+
params={
272+
"country": country,
273+
"countryCode": country_code,
274+
"email": self._username,
275+
"code": code,
276+
# Major and minor version are the user agreement version, we will need to see if this needs to be
277+
# dynamic https://usiot.roborock.com/api/v3/app/agreement/latest?country=US
278+
"majorVersion": 14,
279+
"minorVersion": 0,
280+
},
281+
)
282+
if login_response is None:
283+
raise RoborockException("Login request response is None")
284+
response_code = login_response.get("code")
285+
if response_code != 200:
286+
_LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response)
287+
if response_code == 2018:
288+
raise RoborockInvalidCode("Invalid code - check your code and try again.")
289+
if response_code == 3009:
290+
raise RoborockNoUserAgreement("You must accept the user agreement in the Roborock app to continue.")
291+
if response_code == 3006:
292+
raise RoborockInvalidUserAgreement(
293+
"User agreement must be accepted again - or you are attempting to use the Mi Home app account."
294+
)
295+
raise RoborockException(f"{login_response.get('msg')} - response code: {response_code}")
296+
user_data = login_response.get("data")
297+
if not isinstance(user_data, dict):
298+
raise RoborockException("Got unexpected data type for user_data")
299+
return UserData.from_dict(user_data)
300+
193301
async def pass_login(self, password: str) -> UserData:
194302
try:
195303
self._login_limiter.try_acquire("login")

tests/conftest.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,21 @@ def mock_rest() -> aioresponses:
271271
status=200,
272272
payload={"api": None, "code": 200, "result": None, "status": "ok", "success": True},
273273
)
274+
mocked.post(
275+
re.compile(r"https://.*iot\.roborock\.com/api/v4/email/code/send.*"),
276+
status=200,
277+
payload={"code": 200, "data": None, "msg": "success"},
278+
)
279+
mocked.post(
280+
re.compile(r"https://.*iot\.roborock\.com/api/v3/key/sign.*"),
281+
status=200,
282+
payload={"code": 200, "data": {"k": "mock_k"}, "msg": "success"},
283+
)
284+
mocked.post(
285+
re.compile(r"https://.*iot\.roborock\.com/api/v4/auth/email/login/code.*"),
286+
status=200,
287+
payload={"code": 200, "data": USER_DATA, "msg": "success"},
288+
)
274289
yield mocked
275290

276291

tests/test_web_api.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,11 @@ async def test_execute_scene(mock_rest):
6363
ud = await api.pass_login("password")
6464
await api.execute_scene(ud, 123456)
6565
mock_rest.assert_any_call("https://api-us.roborock.com/user/scene/123456/execute", "post")
66+
67+
68+
async def test_code_login_v4_flow(mock_rest) -> None:
69+
"""Test that we can login with a code and we get back the correct userdata object."""
70+
api = RoborockApiClient(username="test_user@gmail.com")
71+
await api.request_code_v4()
72+
ud = await api.code_login_v4(4123, "US", 1)
73+
assert ud == UserData.from_dict(USER_DATA)

0 commit comments

Comments
 (0)