Skip to content

Commit 2cee9dd

Browse files
authored
fix: cycle through iot urls (#490)
* fix: cycle through iot urls * fix: tests * chore: convert to store all iot login info together * fix: tests * chore: add tests * fix: commenting
1 parent 632f0f4 commit 2cee9dd

File tree

6 files changed

+222
-66
lines changed

6 files changed

+222
-66
lines changed

roborock/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,7 @@ class RoborockTooManyRequest(RoborockException):
7777

7878
class RoborockRateLimit(RoborockException):
7979
"""Class for our rate limits exceptions."""
80+
81+
82+
class RoborockNoResponseFromBaseURL(RoborockException):
83+
"""We could not find an url that had a record of the given account."""

roborock/web_api.py

Lines changed: 87 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import secrets
99
import string
1010
import time
11+
from dataclasses import dataclass
1112

1213
import aiohttp
1314
from aiohttp import ContentTypeError, FormData
@@ -22,14 +23,28 @@
2223
RoborockInvalidEmail,
2324
RoborockInvalidUserAgreement,
2425
RoborockMissingParameters,
26+
RoborockNoResponseFromBaseURL,
2527
RoborockNoUserAgreement,
2628
RoborockRateLimit,
2729
RoborockTooFrequentCodeRequests,
28-
RoborockTooManyRequest,
29-
RoborockUrlException,
3030
)
3131

3232
_LOGGER = logging.getLogger(__name__)
33+
BASE_URLS = [
34+
"https://usiot.roborock.com",
35+
"https://euiot.roborock.com",
36+
"https://cniot.roborock.com",
37+
"https://ruiot.roborock.com",
38+
]
39+
40+
41+
@dataclass
42+
class IotLoginInfo:
43+
"""Information about the login to the iot server."""
44+
45+
base_url: str
46+
country_code: str
47+
country: str
3348

3449

3550
class RoborockApiClient:
@@ -49,41 +64,64 @@ class RoborockApiClient:
4964
_login_limiter = Limiter(_LOGIN_RATES)
5065
_home_data_limiter = Limiter(_HOME_DATA_RATES)
5166

52-
def __init__(self, username: str, base_url=None, session: aiohttp.ClientSession | None = None) -> None:
67+
def __init__(
68+
self, username: str, base_url: str | None = None, session: aiohttp.ClientSession | None = None
69+
) -> None:
5370
"""Sample API Client."""
5471
self._username = username
55-
self._default_url = "https://euiot.roborock.com"
56-
self.base_url = base_url
72+
self._base_url = base_url
5773
self._device_identifier = secrets.token_urlsafe(16)
5874
self.session = session
59-
60-
async def _get_base_url(self) -> str:
61-
if not self.base_url:
62-
url_request = PreparedRequest(self._default_url, self.session)
63-
response = await url_request.request(
64-
"post",
65-
"/api/v1/getUrlByEmail",
66-
params={"email": self._username, "needtwostepauth": "false"},
67-
)
68-
if response is None:
69-
raise RoborockUrlException("get url by email returned None")
70-
response_code = response.get("code")
71-
if response_code != 200:
72-
_LOGGER.info("Get base url failed for %s with the following context: %s", self._username, response)
73-
if response_code == 2003:
74-
raise RoborockInvalidEmail("Your email was incorrectly formatted.")
75-
elif response_code == 1001:
76-
raise RoborockMissingParameters(
77-
"You are missing parameters for this request, are you sure you entered your username?"
75+
self._iot_login_info: IotLoginInfo | None = None
76+
77+
async def _get_iot_login_info(self) -> IotLoginInfo:
78+
if self._iot_login_info is None:
79+
valid_urls = BASE_URLS if self._base_url is None else [self._base_url]
80+
for iot_url in valid_urls:
81+
url_request = PreparedRequest(iot_url, self.session)
82+
response = await url_request.request(
83+
"post",
84+
"/api/v1/getUrlByEmail",
85+
params={"email": self._username, "needtwostepauth": "false"},
86+
)
87+
if response is None:
88+
continue
89+
response_code = response.get("code")
90+
if response_code != 200:
91+
if response_code == 2003:
92+
raise RoborockInvalidEmail("Your email was incorrectly formatted.")
93+
elif response_code == 1001:
94+
raise RoborockMissingParameters(
95+
"You are missing parameters for this request, are you sure you entered your username?"
96+
)
97+
else:
98+
raise RoborockException(f"{response.get('msg')} - response code: {response_code}")
99+
if response["data"]["countrycode"] is not None:
100+
self._iot_login_info = IotLoginInfo(
101+
base_url=response["data"]["url"],
102+
country=response["data"]["country"],
103+
country_code=response["data"]["countrycode"],
78104
)
79-
elif response_code == 9002:
80-
raise RoborockTooManyRequest("Please temporarily disable making requests and try again later.")
81-
raise RoborockUrlException(f"error code: {response_code} msg: {response.get('error')}")
82-
response_data = response.get("data")
83-
if response_data is None:
84-
raise RoborockUrlException("response does not have 'data'")
85-
self.base_url = response_data.get("url")
86-
return self.base_url
105+
return self._iot_login_info
106+
raise RoborockNoResponseFromBaseURL(
107+
"No account was found for any base url we tried. Either your email is incorrect or we do not have a"
108+
" record of the roborock server your device is on."
109+
)
110+
return self._iot_login_info
111+
112+
@property
113+
async def base_url(self):
114+
if self._base_url is not None:
115+
return self._base_url
116+
return (await self._get_iot_login_info()).base_url
117+
118+
@property
119+
async def country(self):
120+
return (await self._get_iot_login_info()).country
121+
122+
@property
123+
async def country_code(self):
124+
return (await self._get_iot_login_info()).country_code
87125

88126
def _get_header_client_id(self):
89127
md5 = hashlib.md5()
@@ -167,7 +205,7 @@ async def request_code(self) -> None:
167205
except BucketFullException as ex:
168206
_LOGGER.info(ex.meta_info)
169207
raise RoborockRateLimit("Reached maximum requests for login. Please try again later.") from ex
170-
base_url = await self._get_base_url()
208+
base_url = await self.base_url
171209
header_clientid = self._get_header_client_id()
172210
code_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
173211

@@ -198,7 +236,7 @@ async def request_code_v4(self) -> None:
198236
except BucketFullException as ex:
199237
_LOGGER.info(ex.meta_info)
200238
raise RoborockRateLimit("Reached maximum requests for login. Please try again later.") from ex
201-
base_url = await self._get_base_url()
239+
base_url = await self.base_url
202240
header_clientid = self._get_header_client_id()
203241
code_request = PreparedRequest(
204242
base_url,
@@ -229,7 +267,7 @@ async def request_code_v4(self) -> None:
229267

230268
async def _sign_key_v3(self, s: str) -> str:
231269
"""Sign a randomly generated string."""
232-
base_url = await self._get_base_url()
270+
base_url = await self.base_url
233271
header_clientid = self._get_header_client_id()
234272
code_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
235273

@@ -249,14 +287,20 @@ async def _sign_key_v3(self, s: str) -> str:
249287

250288
return code_response["data"]["k"]
251289

252-
async def code_login_v4(self, code: int | str, country: str, country_code: int) -> UserData:
290+
async def code_login_v4(
291+
self, code: int | str, country: str | None = None, country_code: int | None = None
292+
) -> UserData:
253293
"""
254294
Login via code authentication.
255295
:param code: The code from the email.
256296
:param country: The two-character representation of the country, i.e. "US"
257297
:param country_code: the country phone number code i.e. 1 for US.
258298
"""
259-
base_url = await self._get_base_url()
299+
base_url = await self.base_url
300+
if country is None:
301+
country = await self.country
302+
if country_code is None:
303+
country_code = await self.country_code
260304
header_clientid = self._get_header_client_id()
261305
x_mercy_ks = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
262306
x_mercy_k = await self._sign_key_v3(x_mercy_ks)
@@ -304,7 +348,7 @@ async def pass_login(self, password: str) -> UserData:
304348
except BucketFullException as ex:
305349
_LOGGER.info(ex.meta_info)
306350
raise RoborockRateLimit("Reached maximum requests for login. Please try again later.") from ex
307-
base_url = await self._get_base_url()
351+
base_url = await self.base_url
308352
header_clientid = self._get_header_client_id()
309353

310354
login_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
@@ -343,7 +387,7 @@ async def pass_login_v3(self, password: str) -> UserData:
343387
raise NotImplementedError("Pass_login_v3 has not yet been implemented")
344388

345389
async def code_login(self, code: int | str) -> UserData:
346-
base_url = await self._get_base_url()
390+
base_url = await self.base_url
347391
header_clientid = self._get_header_client_id()
348392

349393
login_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
@@ -376,7 +420,7 @@ async def code_login(self, code: int | str) -> UserData:
376420
return UserData.from_dict(user_data)
377421

378422
async def _get_home_id(self, user_data: UserData):
379-
base_url = await self._get_base_url()
423+
base_url = await self.base_url
380424
header_clientid = self._get_header_client_id()
381425
home_id_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
382426
home_id_response = await home_id_request.request(
@@ -547,7 +591,7 @@ async def execute_scene(self, user_data: UserData, scene_id: int) -> None:
547591

548592
async def get_products(self, user_data: UserData) -> ProductResponse:
549593
"""Gets all products and their schemas, good for determining status codes and model numbers."""
550-
base_url = await self._get_base_url()
594+
base_url = await self.base_url
551595
header_clientid = self._get_header_client_id()
552596
product_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
553597
product_response = await product_request.request(
@@ -565,7 +609,7 @@ async def get_products(self, user_data: UserData) -> ProductResponse:
565609
raise RoborockException("product result was an unexpected type")
566610

567611
async def download_code(self, user_data: UserData, product_id: int):
568-
base_url = await self._get_base_url()
612+
base_url = await self.base_url
569613
header_clientid = self._get_header_client_id()
570614
product_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
571615
request = {"apilevel": 99999, "productids": [product_id], "type": 2}
@@ -578,7 +622,7 @@ async def download_code(self, user_data: UserData, product_id: int):
578622
return response["data"][0]["url"]
579623

580624
async def download_category_code(self, user_data: UserData):
581-
base_url = await self._get_base_url()
625+
base_url = await self.base_url
582626
header_clientid = self._get_header_client_id()
583627
product_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
584628
response = await product_request.request(

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ def mock_rest() -> aioresponses:
173173
with aioresponses() as mocked:
174174
# Match the base URL and allow any query params
175175
mocked.post(
176-
re.compile(r"https://euiot\.roborock\.com/api/v1/getUrlByEmail.*"),
176+
re.compile(r"https://.*iot\.roborock\.com/api/v1/getUrlByEmail.*"),
177177
status=200,
178178
payload={
179179
"code": 200,

tests/mock_data.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -766,7 +766,7 @@
766766
BASE_URL_REQUEST = {
767767
"code": 200,
768768
"msg": "success",
769-
"data": {"url": "https://sample.com"},
769+
"data": {"url": "https://sample.com", "countrycode": 1, "country": "US"},
770770
}
771771

772772
GET_CODE_RESPONSE = {"code": 200, "msg": "success", "data": None}

tests/test_api.py

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -49,24 +49,28 @@ async def test_get_base_url_no_url():
4949
rc = RoborockApiClient("sample@gmail.com")
5050
with patch("roborock.web_api.PreparedRequest.request") as mock_request:
5151
mock_request.return_value = BASE_URL_REQUEST
52-
await rc._get_base_url()
53-
assert rc.base_url == "https://sample.com"
52+
await rc._get_iot_login_info()
53+
assert await rc.base_url == "https://sample.com"
5454

5555

5656
async def test_request_code():
5757
rc = RoborockApiClient("sample@gmail.com")
58-
with patch("roborock.web_api.RoborockApiClient._get_base_url"), patch(
59-
"roborock.web_api.RoborockApiClient._get_header_client_id"
60-
), patch("roborock.web_api.PreparedRequest.request") as mock_request:
58+
with (
59+
patch("roborock.web_api.RoborockApiClient._get_iot_login_info"),
60+
patch("roborock.web_api.RoborockApiClient._get_header_client_id"),
61+
patch("roborock.web_api.PreparedRequest.request") as mock_request,
62+
):
6163
mock_request.return_value = GET_CODE_RESPONSE
6264
await rc.request_code()
6365

6466

6567
async def test_get_home_data():
6668
rc = RoborockApiClient("sample@gmail.com")
67-
with patch("roborock.web_api.RoborockApiClient._get_base_url"), patch(
68-
"roborock.web_api.RoborockApiClient._get_header_client_id"
69-
), patch("roborock.web_api.PreparedRequest.request") as mock_prepared_request:
69+
with (
70+
patch("roborock.web_api.RoborockApiClient._get_iot_login_info"),
71+
patch("roborock.web_api.RoborockApiClient._get_header_client_id"),
72+
patch("roborock.web_api.PreparedRequest.request") as mock_prepared_request,
73+
):
7074
mock_prepared_request.side_effect = [
7175
{"code": 200, "msg": "success", "data": {"rrHomeId": 1}},
7276
{"code": 200, "success": True, "result": HOME_DATA_RAW},
@@ -117,10 +121,11 @@ async def test_get_prop():
117121
home_data = HomeData.from_dict(HOME_DATA_RAW)
118122
device_info = DeviceData(device=home_data.devices[0], model=home_data.products[0].model)
119123
rmc = RoborockMqttClientV1(UserData.from_dict(USER_DATA), device_info)
120-
with patch("roborock.version_1_apis.roborock_mqtt_client_v1.RoborockMqttClientV1.get_status") as get_status, patch(
121-
"roborock.version_1_apis.roborock_client_v1.RoborockClientV1.send_command"
122-
), patch("roborock.version_1_apis.roborock_client_v1.AttributeCache.async_value"), patch(
123-
"roborock.version_1_apis.roborock_mqtt_client_v1.RoborockMqttClientV1.get_dust_collection_mode"
124+
with (
125+
patch("roborock.version_1_apis.roborock_mqtt_client_v1.RoborockMqttClientV1.get_status") as get_status,
126+
patch("roborock.version_1_apis.roborock_client_v1.RoborockClientV1.send_command"),
127+
patch("roborock.version_1_apis.roborock_client_v1.AttributeCache.async_value"),
128+
patch("roborock.version_1_apis.roborock_mqtt_client_v1.RoborockMqttClientV1.get_dust_collection_mode"),
124129
):
125130
status = S7MaxVStatus.from_dict(STATUS)
126131
status.dock_type = RoborockDockTypeCode.auto_empty_dock_pure
@@ -194,8 +199,9 @@ async def test_disconnect_failure(connected_mqtt_client: RoborockMqttClientV1) -
194199
assert connected_mqtt_client.is_connected()
195200

196201
# Make the MQTT client returns with an error when disconnecting
197-
with patch("roborock.cloud_api.mqtt.Client.disconnect", return_value=mqtt.MQTT_ERR_PROTOCOL), pytest.raises(
198-
RoborockException, match="Failed to disconnect"
202+
with (
203+
patch("roborock.cloud_api.mqtt.Client.disconnect", return_value=mqtt.MQTT_ERR_PROTOCOL),
204+
pytest.raises(RoborockException, match="Failed to disconnect"),
199205
):
200206
await connected_mqtt_client.async_disconnect()
201207

@@ -231,8 +237,9 @@ async def test_subscribe_failure(
231237

232238
response_queue.put(mqtt_packet.gen_connack(rc=0, flags=2))
233239

234-
with patch("roborock.cloud_api.mqtt.Client.subscribe", return_value=(mqtt.MQTT_ERR_NO_CONN, None)), pytest.raises(
235-
RoborockException, match="Failed to subscribe"
240+
with (
241+
patch("roborock.cloud_api.mqtt.Client.subscribe", return_value=(mqtt.MQTT_ERR_NO_CONN, None)),
242+
pytest.raises(RoborockException, match="Failed to subscribe"),
236243
):
237244
await mqtt_client.async_connect()
238245

@@ -298,8 +305,9 @@ async def test_publish_failure(
298305

299306
msg = mqtt.MQTTMessageInfo(0)
300307
msg.rc = mqtt.MQTT_ERR_PROTOCOL
301-
with patch("roborock.cloud_api.mqtt.Client.publish", return_value=msg), pytest.raises(
302-
RoborockException, match="Failed to publish"
308+
with (
309+
patch("roborock.cloud_api.mqtt.Client.publish", return_value=msg),
310+
pytest.raises(RoborockException, match="Failed to publish"),
303311
):
304312
await connected_mqtt_client.get_room_mapping()
305313

@@ -308,7 +316,8 @@ async def test_future_timeout(
308316
connected_mqtt_client: RoborockMqttClientV1,
309317
) -> None:
310318
"""Test a timeout raised while waiting for an RPC response."""
311-
with patch("roborock.roborock_future.async_timeout.timeout", side_effect=asyncio.TimeoutError), pytest.raises(
312-
RoborockTimeout, match="Timeout after"
319+
with (
320+
patch("roborock.roborock_future.async_timeout.timeout", side_effect=asyncio.TimeoutError),
321+
pytest.raises(RoborockTimeout, match="Timeout after"),
313322
):
314323
await connected_mqtt_client.get_room_mapping()

0 commit comments

Comments
 (0)