roborock.web_api
1import base64 2import hashlib 3import hmac 4import logging 5import math 6import secrets 7import string 8import time 9from dataclasses import dataclass 10 11import aiohttp 12from aiohttp import ContentTypeError, FormData 13from pyrate_limiter import Duration, Limiter, Rate 14 15from roborock import HomeDataSchedule 16from roborock.data import HomeData, HomeDataRoom, HomeDataScene, ProductResponse, RRiot, UserData 17from roborock.exceptions import ( 18 RoborockAccountDoesNotExist, 19 RoborockException, 20 RoborockInvalidCode, 21 RoborockInvalidCredentials, 22 RoborockInvalidEmail, 23 RoborockInvalidUserAgreement, 24 RoborockMissingParameters, 25 RoborockNoResponseFromBaseURL, 26 RoborockNoUserAgreement, 27 RoborockRateLimit, 28 RoborockTooFrequentCodeRequests, 29) 30 31_LOGGER = logging.getLogger(__name__) 32BASE_URLS = [ 33 "https://usiot.roborock.com", 34 "https://euiot.roborock.com", 35 "https://cniot.roborock.com", 36 "https://ruiot.roborock.com", 37] 38 39 40@dataclass 41class IotLoginInfo: 42 """Information about the login to the iot server.""" 43 44 base_url: str 45 country_code: str 46 country: str 47 48 49class RoborockApiClient: 50 _LOGIN_RATES = [ 51 Rate(1, Duration.SECOND), 52 Rate(3, Duration.MINUTE), 53 Rate(10, Duration.HOUR), 54 Rate(20, Duration.DAY), 55 ] 56 _HOME_DATA_RATES = [ 57 Rate(1, Duration.SECOND), 58 Rate(3, Duration.MINUTE), 59 Rate(5, Duration.HOUR), 60 Rate(40, Duration.DAY), 61 ] 62 63 _login_limiter = Limiter(_LOGIN_RATES) 64 _home_data_limiter = Limiter(_HOME_DATA_RATES) 65 66 def __init__( 67 self, username: str, base_url: str | None = None, session: aiohttp.ClientSession | None = None 68 ) -> None: 69 """Sample API Client.""" 70 self._username = username 71 self._base_url = base_url 72 self._device_identifier = secrets.token_urlsafe(16) 73 self.session = session 74 self._iot_login_info: IotLoginInfo | None = None 75 self._base_urls = BASE_URLS if base_url is None else [base_url] 76 77 async def _get_iot_login_info(self) -> IotLoginInfo: 78 if self._iot_login_info is None: 79 for iot_url in self._base_urls: 80 url_request = PreparedRequest(iot_url, self.session) 81 response = await url_request.request( 82 "post", 83 "/api/v1/getUrlByEmail", 84 params={"email": self._username, "needtwostepauth": "false"}, 85 ) 86 if response is None: 87 continue 88 response_code = response.get("code") 89 if response_code != 200: 90 if response_code == 2003: 91 raise RoborockInvalidEmail("Your email was incorrectly formatted.") 92 elif response_code == 1001: 93 raise RoborockMissingParameters( 94 "You are missing parameters for this request, are you sure you entered your username?" 95 ) 96 else: 97 raise RoborockException(f"{response.get('msg')} - response code: {response_code}") 98 country_code = response["data"]["countrycode"] 99 country = response["data"]["country"] 100 if country_code is not None or country is not None: 101 self._iot_login_info = IotLoginInfo( 102 base_url=response["data"]["url"], 103 country=country, 104 country_code=country_code, 105 ) 106 _LOGGER.debug("Country determined to be %s and code is %s", country, country_code) 107 return self._iot_login_info 108 raise RoborockNoResponseFromBaseURL( 109 "No account was found for any base url we tried. Either your email is incorrect or we do not have a" 110 " record of the roborock server your device is on." 111 ) 112 return self._iot_login_info 113 114 @property 115 async def base_url(self): 116 if self._base_url is not None: 117 return self._base_url 118 return (await self._get_iot_login_info()).base_url 119 120 @property 121 async def country(self): 122 return (await self._get_iot_login_info()).country 123 124 @property 125 async def country_code(self): 126 return (await self._get_iot_login_info()).country_code 127 128 def _get_header_client_id(self): 129 md5 = hashlib.md5() 130 md5.update(self._username.encode()) 131 md5.update(self._device_identifier.encode()) 132 return base64.b64encode(md5.digest()).decode() 133 134 async def nc_prepare(self, user_data: UserData, timezone: str) -> dict: 135 """This gets a few critical parameters for adding a device to your account.""" 136 if ( 137 user_data.rriot is None 138 or user_data.rriot.r is None 139 or user_data.rriot.u is None 140 or user_data.rriot.r.a is None 141 ): 142 raise RoborockException("Your userdata is missing critical attributes.") 143 base_url = user_data.rriot.r.a 144 prepare_request = PreparedRequest(base_url, self.session) 145 hid = await self._get_home_id(user_data) 146 147 data = FormData() 148 data.add_field("hid", hid) 149 data.add_field("tzid", timezone) 150 151 prepare_response = await prepare_request.request( 152 "post", 153 "/nc/prepare", 154 headers={ 155 "Authorization": _get_hawk_authentication( 156 user_data.rriot, "/nc/prepare", {"hid": hid, "tzid": timezone} 157 ), 158 }, 159 data=data, 160 ) 161 162 if prepare_response is None: 163 raise RoborockException("prepare_response is None") 164 if not prepare_response.get("success"): 165 raise RoborockException(f"{prepare_response.get('msg')} - response code: {prepare_response.get('code')}") 166 167 return prepare_response["result"] 168 169 async def add_device(self, user_data: UserData, s: str, t: str) -> dict: 170 """This will add a new device to your account 171 it is recommended to only use this during a pairing cycle with a device. 172 Please see here: https://github.com/Python-roborock/Roborockmitmproxy/blob/main/handshake_protocol.md 173 """ 174 if ( 175 user_data.rriot is None 176 or user_data.rriot.r is None 177 or user_data.rriot.u is None 178 or user_data.rriot.r.a is None 179 ): 180 raise RoborockException("Your userdata is missing critical attributes.") 181 base_url = user_data.rriot.r.a 182 add_device_request = PreparedRequest(base_url, self.session) 183 184 add_device_response = await add_device_request.request( 185 "GET", 186 "/user/devices/newadd", 187 headers={ 188 "Authorization": _get_hawk_authentication( 189 user_data.rriot, "/user/devices/newadd", params={"s": s, "t": t} 190 ), 191 }, 192 params={"s": s, "t": t}, 193 ) 194 195 if add_device_response is None: 196 raise RoborockException("add_device is None") 197 if not add_device_response.get("success"): 198 raise RoborockException( 199 f"{add_device_response.get('msg')} - response code: {add_device_response.get('code')}" 200 ) 201 202 return add_device_response["result"] 203 204 async def request_code(self) -> None: 205 if not await self._login_limiter.try_acquire_async("login", blocking=True, timeout=1): 206 raise RoborockRateLimit("Reached maximum requests for login. Please try again later.") 207 base_url = await self.base_url 208 header_clientid = self._get_header_client_id() 209 code_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) 210 211 code_response = await code_request.request( 212 "post", 213 "/api/v1/sendEmailCode", 214 params={ 215 "username": self._username, 216 "type": "auth", 217 }, 218 ) 219 if code_response is None: 220 raise RoborockException("Failed to get a response from send email code") 221 response_code = code_response.get("code") 222 if response_code != 200: 223 _LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response) 224 if response_code == 2008: 225 raise RoborockAccountDoesNotExist("Account does not exist - check your login and try again.") 226 elif response_code == 9002: 227 raise RoborockTooFrequentCodeRequests("You have attempted to request too many codes. Try again later") 228 else: 229 raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}") 230 231 async def request_code_v4(self) -> None: 232 """Request a code using the v4 endpoint.""" 233 if await self.country_code is None or await self.country is None: 234 _LOGGER.info("No country code or country found, trying old version of request code.") 235 return await self.request_code() 236 if not await self._login_limiter.try_acquire_async("login", blocking=True, timeout=1): 237 raise RoborockRateLimit("Reached maximum requests for login. Please try again later.") 238 base_url = await self.base_url 239 header_clientid = self._get_header_client_id() 240 code_request = PreparedRequest( 241 base_url, 242 self.session, 243 { 244 "header_clientid": header_clientid, 245 "Content-Type": "application/x-www-form-urlencoded", 246 "header_clientlang": "en", 247 }, 248 ) 249 250 code_response = await code_request.request( 251 "post", 252 "/api/v4/email/code/send", 253 data={"email": self._username, "type": "login", "platform": ""}, 254 ) 255 if code_response is None: 256 raise RoborockException("Failed to get a response from send email code") 257 response_code = code_response.get("code") 258 if response_code != 200: 259 _LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response) 260 if response_code == 2008: 261 raise RoborockAccountDoesNotExist("Account does not exist - check your login and try again.") 262 elif response_code == 9002: 263 raise RoborockTooFrequentCodeRequests("You have attempted to request too many codes. Try again later") 264 elif response_code == 3030 and len(self._base_urls) > 1: 265 self._base_urls = self._base_urls[1:] 266 self._iot_login_info = None 267 return await self.request_code_v4() 268 else: 269 raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}") 270 271 async def _sign_key_v3(self, s: str) -> str: 272 """Sign a randomly generated string.""" 273 base_url = await self.base_url 274 header_clientid = self._get_header_client_id() 275 code_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) 276 277 code_response = await code_request.request( 278 "post", 279 "/api/v3/key/sign", 280 params={"s": s}, 281 ) 282 283 if not code_response or "data" not in code_response or "k" not in code_response["data"]: 284 raise RoborockException("Failed to get a response from sign key") 285 response_code = code_response.get("code") 286 287 if response_code != 200: 288 _LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response) 289 raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}") 290 291 return code_response["data"]["k"] 292 293 async def code_login_v4( 294 self, code: int | str, country: str | None = None, country_code: int | None = None 295 ) -> UserData: 296 """ 297 Login via code authentication. 298 :param code: The code from the email. 299 :param country: The two-character representation of the country, i.e. "US" 300 :param country_code: the country phone number code i.e. 1 for US. 301 """ 302 base_url = await self.base_url 303 if country is None: 304 country = await self.country 305 if country_code is None: 306 country_code = await self.country_code 307 if country_code is None or country is None: 308 _LOGGER.info("No country code or country found, trying old version of code login.") 309 return await self.code_login(code) 310 header_clientid = self._get_header_client_id() 311 x_mercy_ks = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16)) 312 x_mercy_k = await self._sign_key_v3(x_mercy_ks) 313 login_request = PreparedRequest( 314 base_url, 315 self.session, 316 { 317 "header_clientid": header_clientid, 318 "x-mercy-ks": x_mercy_ks, 319 "x-mercy-k": x_mercy_k, 320 "Content-Type": "application/x-www-form-urlencoded", 321 "header_clientlang": "en", 322 "header_appversion": "4.54.02", 323 "header_phonesystem": "iOS", 324 "header_phonemodel": "iPhone16,1", 325 }, 326 ) 327 login_response = await login_request.request( 328 "post", 329 "/api/v4/auth/email/login/code", 330 data={ 331 "country": country, 332 "countryCode": country_code, 333 "email": self._username, 334 "code": code, 335 # Major and minor version are the user agreement version, we will need to see if this needs to be 336 # dynamic https://usiot.roborock.com/api/v3/app/agreement/latest?country=US 337 "majorVersion": 14, 338 "minorVersion": 0, 339 }, 340 ) 341 if login_response is None: 342 raise RoborockException("Login request response is None") 343 response_code = login_response.get("code") 344 if response_code != 200: 345 _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response) 346 if response_code == 2018: 347 raise RoborockInvalidCode("Invalid code - check your code and try again.") 348 if response_code == 3009: 349 raise RoborockNoUserAgreement("You must accept the user agreement in the Roborock app to continue.") 350 if response_code == 3006: 351 raise RoborockInvalidUserAgreement( 352 "User agreement must be accepted again - or you are attempting to use the Mi Home app account." 353 ) 354 if response_code == 3039: 355 raise RoborockAccountDoesNotExist( 356 "This account does not exist - please ensure that you selected the right region and email." 357 ) 358 raise RoborockException(f"{login_response.get('msg')} - response code: {response_code}") 359 user_data = login_response.get("data") 360 if not isinstance(user_data, dict): 361 raise RoborockException("Got unexpected data type for user_data") 362 return UserData.from_dict(user_data) 363 364 async def pass_login(self, password: str) -> UserData: 365 if not await self._login_limiter.try_acquire_async("login", blocking=True, timeout=1): 366 raise RoborockRateLimit("Reached maximum requests for login. Please try again later.") 367 base_url = await self.base_url 368 header_clientid = self._get_header_client_id() 369 370 login_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) 371 login_response = await login_request.request( 372 "post", 373 "/api/v1/login", 374 params={ 375 "username": self._username, 376 "password": password, 377 "needtwostepauth": "false", 378 }, 379 ) 380 if login_response is None: 381 raise RoborockException("Login response is none") 382 if login_response.get("code") != 200: 383 _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response) 384 raise RoborockException(f"{login_response.get('msg')} - response code: {login_response.get('code')}") 385 user_data = login_response.get("data") 386 if not isinstance(user_data, dict): 387 raise RoborockException("Got unexpected data type for user_data") 388 return UserData.from_dict(user_data) 389 390 async def pass_login_v3(self, password: str) -> UserData: 391 """Seemingly it follows the format below, but password is encrypted in some manner. 392 # login_response = await login_request.request( 393 # "post", 394 # "/api/v3/auth/email/login", 395 # params={ 396 # "email": self._username, 397 # "password": password, 398 # "twoStep": 1, 399 # "version": 0 400 # }, 401 # ) 402 """ 403 raise NotImplementedError("Pass_login_v3 has not yet been implemented") 404 405 async def code_login(self, code: int | str) -> UserData: 406 base_url = await self.base_url 407 header_clientid = self._get_header_client_id() 408 409 login_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) 410 login_response = await login_request.request( 411 "post", 412 "/api/v1/loginWithCode", 413 params={ 414 "username": self._username, 415 "verifycode": code, 416 "verifycodetype": "AUTH_EMAIL_CODE", 417 }, 418 ) 419 if login_response is None: 420 raise RoborockException("Login request response is None") 421 response_code = login_response.get("code") 422 if response_code != 200: 423 _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response) 424 if response_code == 2018: 425 raise RoborockInvalidCode("Invalid code - check your code and try again.") 426 if response_code == 3009: 427 raise RoborockNoUserAgreement("You must accept the user agreement in the Roborock app to continue.") 428 if response_code == 3006: 429 raise RoborockInvalidUserAgreement( 430 "User agreement must be accepted again - or you are attempting to use the Mi Home app account." 431 ) 432 raise RoborockException(f"{login_response.get('msg')} - response code: {response_code}") 433 user_data = login_response.get("data") 434 if not isinstance(user_data, dict): 435 raise RoborockException("Got unexpected data type for user_data") 436 return UserData.from_dict(user_data) 437 438 async def _get_home_id(self, user_data: UserData): 439 base_url = await self.base_url 440 header_clientid = self._get_header_client_id() 441 home_id_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) 442 home_id_response = await home_id_request.request( 443 "get", 444 "/api/v1/getHomeDetail", 445 headers={"Authorization": user_data.token}, 446 ) 447 if home_id_response is None: 448 raise RoborockException("home_id_response is None") 449 if home_id_response.get("code") != 200: 450 _LOGGER.info("Get Home Id failed with the following context: %s", home_id_response) 451 if home_id_response.get("code") == 2010: 452 raise RoborockInvalidCredentials( 453 f"Invalid credentials ({home_id_response.get('msg')}) - check your login and try again." 454 ) 455 raise RoborockException(f"{home_id_response.get('msg')} - response code: {home_id_response.get('code')}") 456 457 return home_id_response["data"]["rrHomeId"] 458 459 async def get_home_data(self, user_data: UserData) -> HomeData: 460 if not self._home_data_limiter.try_acquire("home_data", blocking=False): 461 raise RoborockRateLimit("Reached maximum requests for home data. Please try again later.") 462 rriot = user_data.rriot 463 if rriot is None: 464 raise RoborockException("rriot is none") 465 home_id = await self._get_home_id(user_data) 466 if rriot.r.a is None: 467 raise RoborockException("Missing field 'a' in rriot reference") 468 home_request = PreparedRequest( 469 rriot.r.a, 470 self.session, 471 { 472 "Authorization": _get_hawk_authentication(rriot, f"/user/homes/{str(home_id)}"), 473 }, 474 ) 475 home_response = await home_request.request("get", "/user/homes/" + str(home_id)) 476 if not home_response.get("success"): 477 raise RoborockException(home_response) 478 home_data = home_response.get("result") 479 if isinstance(home_data, dict): 480 return HomeData.from_dict(home_data) 481 else: 482 raise RoborockException("home_response result was an unexpected type") 483 484 async def get_home_data_v2(self, user_data: UserData) -> HomeData: 485 """This is the same as get_home_data, but uses a different endpoint and includes non-robotic vacuums.""" 486 if not self._home_data_limiter.try_acquire("home_data", blocking=False): 487 raise RoborockRateLimit("Reached maximum requests for home data. Please try again later.") 488 rriot = user_data.rriot 489 if rriot is None: 490 raise RoborockException("rriot is none") 491 home_id = await self._get_home_id(user_data) 492 if rriot.r.a is None: 493 raise RoborockException("Missing field 'a' in rriot reference") 494 home_request = PreparedRequest( 495 rriot.r.a, 496 self.session, 497 { 498 "Authorization": _get_hawk_authentication(rriot, "/v2/user/homes/" + str(home_id)), 499 }, 500 ) 501 home_response = await home_request.request("get", "/v2/user/homes/" + str(home_id)) 502 if not home_response.get("success"): 503 raise RoborockException(home_response) 504 home_data = home_response.get("result") 505 if isinstance(home_data, dict): 506 return HomeData.from_dict(home_data) 507 else: 508 raise RoborockException("home_response result was an unexpected type") 509 510 async def get_home_data_v3(self, user_data: UserData) -> HomeData: 511 """This is the same as get_home_data, but uses a different endpoint and includes non-robotic vacuums.""" 512 if not self._home_data_limiter.try_acquire("home_data", blocking=False): 513 raise RoborockRateLimit("Reached maximum requests for home data. Please try again later.") 514 rriot = user_data.rriot 515 home_id = await self._get_home_id(user_data) 516 if rriot.r.a is None: 517 raise RoborockException("Missing field 'a' in rriot reference") 518 home_request = PreparedRequest( 519 rriot.r.a, 520 self.session, 521 { 522 "Authorization": _get_hawk_authentication(rriot, "/v3/user/homes/" + str(home_id)), 523 }, 524 ) 525 home_response = await home_request.request("get", "/v3/user/homes/" + str(home_id)) 526 if not home_response.get("success"): 527 raise RoborockException(home_response) 528 home_data = home_response.get("result") 529 if isinstance(home_data, dict): 530 return HomeData.from_dict(home_data) 531 raise RoborockException(f"home_response result was an unexpected type: {home_data}") 532 533 async def get_rooms(self, user_data: UserData, home_id: int | None = None) -> list[HomeDataRoom]: 534 rriot = user_data.rriot 535 if rriot is None: 536 raise RoborockException("rriot is none") 537 if home_id is None: 538 home_id = await self._get_home_id(user_data) 539 if rriot.r.a is None: 540 raise RoborockException("Missing field 'a' in rriot reference") 541 room_request = PreparedRequest( 542 rriot.r.a, 543 self.session, 544 { 545 "Authorization": _get_hawk_authentication(rriot, f"/user/homes/{home_id}/rooms"), 546 }, 547 ) 548 room_response = await room_request.request("get", f"/user/homes/{home_id}/rooms") 549 if not room_response.get("success"): 550 raise RoborockException(room_response) 551 rooms = room_response.get("result") 552 if isinstance(rooms, list): 553 output_list = [] 554 for room in rooms: 555 output_list.append(HomeDataRoom.from_dict(room)) 556 return output_list 557 else: 558 raise RoborockException("home_response result was an unexpected type") 559 560 async def get_scenes(self, user_data: UserData, device_id: str) -> list[HomeDataScene]: 561 rriot = user_data.rriot 562 if rriot is None: 563 raise RoborockException("rriot is none") 564 if rriot.r.a is None: 565 raise RoborockException("Missing field 'a' in rriot reference") 566 scenes_request = PreparedRequest( 567 rriot.r.a, 568 self.session, 569 { 570 "Authorization": _get_hawk_authentication(rriot, f"/user/scene/device/{str(device_id)}"), 571 }, 572 ) 573 scenes_response = await scenes_request.request("get", f"/user/scene/device/{str(device_id)}") 574 if not scenes_response.get("success"): 575 raise RoborockException(scenes_response) 576 scenes = scenes_response.get("result") 577 if isinstance(scenes, list): 578 return [HomeDataScene.from_dict(scene) for scene in scenes] 579 else: 580 raise RoborockException("scene_response result was an unexpected type") 581 582 async def execute_scene(self, user_data: UserData, scene_id: int) -> None: 583 rriot = user_data.rriot 584 if rriot is None: 585 raise RoborockException("rriot is none") 586 if rriot.r.a is None: 587 raise RoborockException("Missing field 'a' in rriot reference") 588 execute_scene_request = PreparedRequest( 589 rriot.r.a, 590 self.session, 591 { 592 "Authorization": _get_hawk_authentication(rriot, f"/user/scene/{str(scene_id)}/execute"), 593 }, 594 ) 595 execute_scene_response = await execute_scene_request.request("POST", f"/user/scene/{str(scene_id)}/execute") 596 if not execute_scene_response.get("success"): 597 raise RoborockException(execute_scene_response) 598 599 async def get_schedules(self, user_data: UserData, device_id: str) -> list[HomeDataSchedule]: 600 rriot = user_data.rriot 601 if rriot is None: 602 raise RoborockException("rriot is none") 603 if rriot.r.a is None: 604 raise RoborockException("Missing field 'a' in rriot reference") 605 schedules_request = PreparedRequest( 606 rriot.r.a, 607 self.session, 608 { 609 "Authorization": _get_hawk_authentication(rriot, f"/user/devices/{device_id}/jobs"), 610 }, 611 ) 612 schedules_response = await schedules_request.request("get", f"/user/devices/{str(device_id)}/jobs") 613 if not schedules_response.get("success"): 614 raise RoborockException(schedules_response) 615 schedules = schedules_response.get("result") 616 if isinstance(schedules, list): 617 return [HomeDataSchedule.from_dict(schedule) for schedule in schedules] 618 else: 619 raise RoborockException(f"schedule_response result was an unexpected type: {schedules}") 620 621 async def get_products(self, user_data: UserData) -> ProductResponse: 622 """Gets all products and their schemas, good for determining status codes and model numbers.""" 623 base_url = await self.base_url 624 header_clientid = self._get_header_client_id() 625 product_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) 626 product_response = await product_request.request( 627 "get", 628 "/api/v4/product", 629 headers={"Authorization": user_data.token}, 630 ) 631 if product_response is None: 632 raise RoborockException("home_id_response is None") 633 if product_response.get("code") != 200: 634 raise RoborockException(f"{product_response.get('msg')} - response code: {product_response.get('code')}") 635 result = product_response.get("data") 636 if isinstance(result, dict): 637 return ProductResponse.from_dict(result) 638 raise RoborockException("product result was an unexpected type") 639 640 async def download_code(self, user_data: UserData, product_id: int): 641 base_url = await self.base_url 642 header_clientid = self._get_header_client_id() 643 product_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) 644 request = {"apilevel": 99999, "productids": [product_id], "type": 2} 645 response = await product_request.request( 646 "post", 647 "/api/v1/appplugin", 648 json=request, 649 headers={"Authorization": user_data.token, "Content-Type": "application/json"}, 650 ) 651 return response["data"][0]["url"] 652 653 async def download_category_code(self, user_data: UserData): 654 base_url = await self.base_url 655 header_clientid = self._get_header_client_id() 656 product_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) 657 response = await product_request.request( 658 "get", 659 "api/v1/plugins?apiLevel=99999&type=2", 660 headers={ 661 "Authorization": user_data.token, 662 }, 663 ) 664 return {r["category"]: r["url"] for r in response["data"]["categoryPluginList"]} 665 666 667class PreparedRequest: 668 def __init__( 669 self, base_url: str, session: aiohttp.ClientSession | None = None, base_headers: dict | None = None 670 ) -> None: 671 self.base_url = base_url 672 self.base_headers = base_headers or {} 673 self.session = session 674 675 async def request(self, method: str, url: str, params=None, data=None, headers=None, json=None) -> dict: 676 _url = "/".join(s.strip("/") for s in [self.base_url, url]) 677 _headers = {**self.base_headers, **(headers or {})} 678 close_session = self.session is None 679 session = self.session if self.session is not None else aiohttp.ClientSession() 680 try: 681 async with session.request(method, _url, params=params, data=data, headers=_headers, json=json) as resp: 682 return await resp.json() 683 except ContentTypeError as err: 684 """If we get an error, lets log everything for debugging.""" 685 try: 686 resp_json = await resp.json(content_type=None) 687 _LOGGER.info("Resp: %s", resp_json) 688 except ContentTypeError as err_2: 689 _LOGGER.info(err_2) 690 resp_raw = await resp.read() 691 _LOGGER.info("Resp raw: %s", resp_raw) 692 # Still raise the err so that it's clear it failed. 693 raise err 694 finally: 695 if close_session: 696 await session.close() 697 698 699def _process_extra_hawk_values(values: dict | None) -> str: 700 if values is None: 701 return "" 702 else: 703 sorted_keys = sorted(values.keys()) 704 result = [] 705 for key in sorted_keys: 706 value = values.get(key) 707 result.append(f"{key}={value}") 708 return hashlib.md5("&".join(result).encode()).hexdigest() 709 710 711def _get_hawk_authentication(rriot: RRiot, url: str, formdata: dict | None = None, params: dict | None = None) -> str: 712 timestamp = math.floor(time.time()) 713 nonce = secrets.token_urlsafe(6) 714 formdata_str = _process_extra_hawk_values(formdata) 715 params_str = _process_extra_hawk_values(params) 716 717 prestr = ":".join( 718 [ 719 rriot.u, 720 rriot.s, 721 nonce, 722 str(timestamp), 723 hashlib.md5(url.encode()).hexdigest(), 724 params_str, 725 formdata_str, 726 ] 727 ) 728 mac = base64.b64encode(hmac.new(rriot.h.encode(), prestr.encode(), hashlib.sha256).digest()).decode() 729 return f'Hawk id="{rriot.u}",s="{rriot.s}",ts="{timestamp}",nonce="{nonce}",mac="{mac}"' 730 731 732class UserWebApiClient: 733 """Wrapper around RoborockApiClient to provide information for a specific user. 734 735 This binds a RoborockApiClient to a specific user context with the 736 provided UserData. This allows for easier access to user-specific data, 737 to avoid needing to pass UserData around and mock out the web API. 738 """ 739 740 def __init__(self, web_api: RoborockApiClient, user_data: UserData) -> None: 741 """Initialize the wrapper with the API client and user data.""" 742 self._web_api = web_api 743 self._user_data = user_data 744 745 async def get_home_data(self) -> HomeData: 746 """Fetch home data using the API client.""" 747 return await self._web_api.get_home_data_v3(self._user_data) 748 749 async def get_routines(self, device_id: str) -> list[HomeDataScene]: 750 """Fetch routines (scenes) for a specific device.""" 751 return await self._web_api.get_scenes(self._user_data, device_id) 752 753 async def get_rooms(self) -> list[HomeDataRoom]: 754 """Fetch rooms using the API client.""" 755 return await self._web_api.get_rooms(self._user_data) 756 757 async def execute_routine(self, scene_id: int) -> None: 758 """Execute a specific routine (scene) by its ID.""" 759 await self._web_api.execute_scene(self._user_data, scene_id)
41@dataclass 42class IotLoginInfo: 43 """Information about the login to the iot server.""" 44 45 base_url: str 46 country_code: str 47 country: str
Information about the login to the iot server.
50class RoborockApiClient: 51 _LOGIN_RATES = [ 52 Rate(1, Duration.SECOND), 53 Rate(3, Duration.MINUTE), 54 Rate(10, Duration.HOUR), 55 Rate(20, Duration.DAY), 56 ] 57 _HOME_DATA_RATES = [ 58 Rate(1, Duration.SECOND), 59 Rate(3, Duration.MINUTE), 60 Rate(5, Duration.HOUR), 61 Rate(40, Duration.DAY), 62 ] 63 64 _login_limiter = Limiter(_LOGIN_RATES) 65 _home_data_limiter = Limiter(_HOME_DATA_RATES) 66 67 def __init__( 68 self, username: str, base_url: str | None = None, session: aiohttp.ClientSession | None = None 69 ) -> None: 70 """Sample API Client.""" 71 self._username = username 72 self._base_url = base_url 73 self._device_identifier = secrets.token_urlsafe(16) 74 self.session = session 75 self._iot_login_info: IotLoginInfo | None = None 76 self._base_urls = BASE_URLS if base_url is None else [base_url] 77 78 async def _get_iot_login_info(self) -> IotLoginInfo: 79 if self._iot_login_info is None: 80 for iot_url in self._base_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 country_code = response["data"]["countrycode"] 100 country = response["data"]["country"] 101 if country_code is not None or country is not None: 102 self._iot_login_info = IotLoginInfo( 103 base_url=response["data"]["url"], 104 country=country, 105 country_code=country_code, 106 ) 107 _LOGGER.debug("Country determined to be %s and code is %s", country, country_code) 108 return self._iot_login_info 109 raise RoborockNoResponseFromBaseURL( 110 "No account was found for any base url we tried. Either your email is incorrect or we do not have a" 111 " record of the roborock server your device is on." 112 ) 113 return self._iot_login_info 114 115 @property 116 async def base_url(self): 117 if self._base_url is not None: 118 return self._base_url 119 return (await self._get_iot_login_info()).base_url 120 121 @property 122 async def country(self): 123 return (await self._get_iot_login_info()).country 124 125 @property 126 async def country_code(self): 127 return (await self._get_iot_login_info()).country_code 128 129 def _get_header_client_id(self): 130 md5 = hashlib.md5() 131 md5.update(self._username.encode()) 132 md5.update(self._device_identifier.encode()) 133 return base64.b64encode(md5.digest()).decode() 134 135 async def nc_prepare(self, user_data: UserData, timezone: str) -> dict: 136 """This gets a few critical parameters for adding a device to your account.""" 137 if ( 138 user_data.rriot is None 139 or user_data.rriot.r is None 140 or user_data.rriot.u is None 141 or user_data.rriot.r.a is None 142 ): 143 raise RoborockException("Your userdata is missing critical attributes.") 144 base_url = user_data.rriot.r.a 145 prepare_request = PreparedRequest(base_url, self.session) 146 hid = await self._get_home_id(user_data) 147 148 data = FormData() 149 data.add_field("hid", hid) 150 data.add_field("tzid", timezone) 151 152 prepare_response = await prepare_request.request( 153 "post", 154 "/nc/prepare", 155 headers={ 156 "Authorization": _get_hawk_authentication( 157 user_data.rriot, "/nc/prepare", {"hid": hid, "tzid": timezone} 158 ), 159 }, 160 data=data, 161 ) 162 163 if prepare_response is None: 164 raise RoborockException("prepare_response is None") 165 if not prepare_response.get("success"): 166 raise RoborockException(f"{prepare_response.get('msg')} - response code: {prepare_response.get('code')}") 167 168 return prepare_response["result"] 169 170 async def add_device(self, user_data: UserData, s: str, t: str) -> dict: 171 """This will add a new device to your account 172 it is recommended to only use this during a pairing cycle with a device. 173 Please see here: https://github.com/Python-roborock/Roborockmitmproxy/blob/main/handshake_protocol.md 174 """ 175 if ( 176 user_data.rriot is None 177 or user_data.rriot.r is None 178 or user_data.rriot.u is None 179 or user_data.rriot.r.a is None 180 ): 181 raise RoborockException("Your userdata is missing critical attributes.") 182 base_url = user_data.rriot.r.a 183 add_device_request = PreparedRequest(base_url, self.session) 184 185 add_device_response = await add_device_request.request( 186 "GET", 187 "/user/devices/newadd", 188 headers={ 189 "Authorization": _get_hawk_authentication( 190 user_data.rriot, "/user/devices/newadd", params={"s": s, "t": t} 191 ), 192 }, 193 params={"s": s, "t": t}, 194 ) 195 196 if add_device_response is None: 197 raise RoborockException("add_device is None") 198 if not add_device_response.get("success"): 199 raise RoborockException( 200 f"{add_device_response.get('msg')} - response code: {add_device_response.get('code')}" 201 ) 202 203 return add_device_response["result"] 204 205 async def request_code(self) -> None: 206 if not await self._login_limiter.try_acquire_async("login", blocking=True, timeout=1): 207 raise RoborockRateLimit("Reached maximum requests for login. Please try again later.") 208 base_url = await self.base_url 209 header_clientid = self._get_header_client_id() 210 code_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) 211 212 code_response = await code_request.request( 213 "post", 214 "/api/v1/sendEmailCode", 215 params={ 216 "username": self._username, 217 "type": "auth", 218 }, 219 ) 220 if code_response is None: 221 raise RoborockException("Failed to get a response from send email code") 222 response_code = code_response.get("code") 223 if response_code != 200: 224 _LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response) 225 if response_code == 2008: 226 raise RoborockAccountDoesNotExist("Account does not exist - check your login and try again.") 227 elif response_code == 9002: 228 raise RoborockTooFrequentCodeRequests("You have attempted to request too many codes. Try again later") 229 else: 230 raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}") 231 232 async def request_code_v4(self) -> None: 233 """Request a code using the v4 endpoint.""" 234 if await self.country_code is None or await self.country is None: 235 _LOGGER.info("No country code or country found, trying old version of request code.") 236 return await self.request_code() 237 if not await self._login_limiter.try_acquire_async("login", blocking=True, timeout=1): 238 raise RoborockRateLimit("Reached maximum requests for login. Please try again later.") 239 base_url = await self.base_url 240 header_clientid = self._get_header_client_id() 241 code_request = PreparedRequest( 242 base_url, 243 self.session, 244 { 245 "header_clientid": header_clientid, 246 "Content-Type": "application/x-www-form-urlencoded", 247 "header_clientlang": "en", 248 }, 249 ) 250 251 code_response = await code_request.request( 252 "post", 253 "/api/v4/email/code/send", 254 data={"email": self._username, "type": "login", "platform": ""}, 255 ) 256 if code_response is None: 257 raise RoborockException("Failed to get a response from send email code") 258 response_code = code_response.get("code") 259 if response_code != 200: 260 _LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response) 261 if response_code == 2008: 262 raise RoborockAccountDoesNotExist("Account does not exist - check your login and try again.") 263 elif response_code == 9002: 264 raise RoborockTooFrequentCodeRequests("You have attempted to request too many codes. Try again later") 265 elif response_code == 3030 and len(self._base_urls) > 1: 266 self._base_urls = self._base_urls[1:] 267 self._iot_login_info = None 268 return await self.request_code_v4() 269 else: 270 raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}") 271 272 async def _sign_key_v3(self, s: str) -> str: 273 """Sign a randomly generated string.""" 274 base_url = await self.base_url 275 header_clientid = self._get_header_client_id() 276 code_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) 277 278 code_response = await code_request.request( 279 "post", 280 "/api/v3/key/sign", 281 params={"s": s}, 282 ) 283 284 if not code_response or "data" not in code_response or "k" not in code_response["data"]: 285 raise RoborockException("Failed to get a response from sign key") 286 response_code = code_response.get("code") 287 288 if response_code != 200: 289 _LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response) 290 raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}") 291 292 return code_response["data"]["k"] 293 294 async def code_login_v4( 295 self, code: int | str, country: str | None = None, country_code: int | None = None 296 ) -> UserData: 297 """ 298 Login via code authentication. 299 :param code: The code from the email. 300 :param country: The two-character representation of the country, i.e. "US" 301 :param country_code: the country phone number code i.e. 1 for US. 302 """ 303 base_url = await self.base_url 304 if country is None: 305 country = await self.country 306 if country_code is None: 307 country_code = await self.country_code 308 if country_code is None or country is None: 309 _LOGGER.info("No country code or country found, trying old version of code login.") 310 return await self.code_login(code) 311 header_clientid = self._get_header_client_id() 312 x_mercy_ks = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16)) 313 x_mercy_k = await self._sign_key_v3(x_mercy_ks) 314 login_request = PreparedRequest( 315 base_url, 316 self.session, 317 { 318 "header_clientid": header_clientid, 319 "x-mercy-ks": x_mercy_ks, 320 "x-mercy-k": x_mercy_k, 321 "Content-Type": "application/x-www-form-urlencoded", 322 "header_clientlang": "en", 323 "header_appversion": "4.54.02", 324 "header_phonesystem": "iOS", 325 "header_phonemodel": "iPhone16,1", 326 }, 327 ) 328 login_response = await login_request.request( 329 "post", 330 "/api/v4/auth/email/login/code", 331 data={ 332 "country": country, 333 "countryCode": country_code, 334 "email": self._username, 335 "code": code, 336 # Major and minor version are the user agreement version, we will need to see if this needs to be 337 # dynamic https://usiot.roborock.com/api/v3/app/agreement/latest?country=US 338 "majorVersion": 14, 339 "minorVersion": 0, 340 }, 341 ) 342 if login_response is None: 343 raise RoborockException("Login request response is None") 344 response_code = login_response.get("code") 345 if response_code != 200: 346 _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response) 347 if response_code == 2018: 348 raise RoborockInvalidCode("Invalid code - check your code and try again.") 349 if response_code == 3009: 350 raise RoborockNoUserAgreement("You must accept the user agreement in the Roborock app to continue.") 351 if response_code == 3006: 352 raise RoborockInvalidUserAgreement( 353 "User agreement must be accepted again - or you are attempting to use the Mi Home app account." 354 ) 355 if response_code == 3039: 356 raise RoborockAccountDoesNotExist( 357 "This account does not exist - please ensure that you selected the right region and email." 358 ) 359 raise RoborockException(f"{login_response.get('msg')} - response code: {response_code}") 360 user_data = login_response.get("data") 361 if not isinstance(user_data, dict): 362 raise RoborockException("Got unexpected data type for user_data") 363 return UserData.from_dict(user_data) 364 365 async def pass_login(self, password: str) -> UserData: 366 if not await self._login_limiter.try_acquire_async("login", blocking=True, timeout=1): 367 raise RoborockRateLimit("Reached maximum requests for login. Please try again later.") 368 base_url = await self.base_url 369 header_clientid = self._get_header_client_id() 370 371 login_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) 372 login_response = await login_request.request( 373 "post", 374 "/api/v1/login", 375 params={ 376 "username": self._username, 377 "password": password, 378 "needtwostepauth": "false", 379 }, 380 ) 381 if login_response is None: 382 raise RoborockException("Login response is none") 383 if login_response.get("code") != 200: 384 _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response) 385 raise RoborockException(f"{login_response.get('msg')} - response code: {login_response.get('code')}") 386 user_data = login_response.get("data") 387 if not isinstance(user_data, dict): 388 raise RoborockException("Got unexpected data type for user_data") 389 return UserData.from_dict(user_data) 390 391 async def pass_login_v3(self, password: str) -> UserData: 392 """Seemingly it follows the format below, but password is encrypted in some manner. 393 # login_response = await login_request.request( 394 # "post", 395 # "/api/v3/auth/email/login", 396 # params={ 397 # "email": self._username, 398 # "password": password, 399 # "twoStep": 1, 400 # "version": 0 401 # }, 402 # ) 403 """ 404 raise NotImplementedError("Pass_login_v3 has not yet been implemented") 405 406 async def code_login(self, code: int | str) -> UserData: 407 base_url = await self.base_url 408 header_clientid = self._get_header_client_id() 409 410 login_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) 411 login_response = await login_request.request( 412 "post", 413 "/api/v1/loginWithCode", 414 params={ 415 "username": self._username, 416 "verifycode": code, 417 "verifycodetype": "AUTH_EMAIL_CODE", 418 }, 419 ) 420 if login_response is None: 421 raise RoborockException("Login request response is None") 422 response_code = login_response.get("code") 423 if response_code != 200: 424 _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response) 425 if response_code == 2018: 426 raise RoborockInvalidCode("Invalid code - check your code and try again.") 427 if response_code == 3009: 428 raise RoborockNoUserAgreement("You must accept the user agreement in the Roborock app to continue.") 429 if response_code == 3006: 430 raise RoborockInvalidUserAgreement( 431 "User agreement must be accepted again - or you are attempting to use the Mi Home app account." 432 ) 433 raise RoborockException(f"{login_response.get('msg')} - response code: {response_code}") 434 user_data = login_response.get("data") 435 if not isinstance(user_data, dict): 436 raise RoborockException("Got unexpected data type for user_data") 437 return UserData.from_dict(user_data) 438 439 async def _get_home_id(self, user_data: UserData): 440 base_url = await self.base_url 441 header_clientid = self._get_header_client_id() 442 home_id_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) 443 home_id_response = await home_id_request.request( 444 "get", 445 "/api/v1/getHomeDetail", 446 headers={"Authorization": user_data.token}, 447 ) 448 if home_id_response is None: 449 raise RoborockException("home_id_response is None") 450 if home_id_response.get("code") != 200: 451 _LOGGER.info("Get Home Id failed with the following context: %s", home_id_response) 452 if home_id_response.get("code") == 2010: 453 raise RoborockInvalidCredentials( 454 f"Invalid credentials ({home_id_response.get('msg')}) - check your login and try again." 455 ) 456 raise RoborockException(f"{home_id_response.get('msg')} - response code: {home_id_response.get('code')}") 457 458 return home_id_response["data"]["rrHomeId"] 459 460 async def get_home_data(self, user_data: UserData) -> HomeData: 461 if not self._home_data_limiter.try_acquire("home_data", blocking=False): 462 raise RoborockRateLimit("Reached maximum requests for home data. Please try again later.") 463 rriot = user_data.rriot 464 if rriot is None: 465 raise RoborockException("rriot is none") 466 home_id = await self._get_home_id(user_data) 467 if rriot.r.a is None: 468 raise RoborockException("Missing field 'a' in rriot reference") 469 home_request = PreparedRequest( 470 rriot.r.a, 471 self.session, 472 { 473 "Authorization": _get_hawk_authentication(rriot, f"/user/homes/{str(home_id)}"), 474 }, 475 ) 476 home_response = await home_request.request("get", "/user/homes/" + str(home_id)) 477 if not home_response.get("success"): 478 raise RoborockException(home_response) 479 home_data = home_response.get("result") 480 if isinstance(home_data, dict): 481 return HomeData.from_dict(home_data) 482 else: 483 raise RoborockException("home_response result was an unexpected type") 484 485 async def get_home_data_v2(self, user_data: UserData) -> HomeData: 486 """This is the same as get_home_data, but uses a different endpoint and includes non-robotic vacuums.""" 487 if not self._home_data_limiter.try_acquire("home_data", blocking=False): 488 raise RoborockRateLimit("Reached maximum requests for home data. Please try again later.") 489 rriot = user_data.rriot 490 if rriot is None: 491 raise RoborockException("rriot is none") 492 home_id = await self._get_home_id(user_data) 493 if rriot.r.a is None: 494 raise RoborockException("Missing field 'a' in rriot reference") 495 home_request = PreparedRequest( 496 rriot.r.a, 497 self.session, 498 { 499 "Authorization": _get_hawk_authentication(rriot, "/v2/user/homes/" + str(home_id)), 500 }, 501 ) 502 home_response = await home_request.request("get", "/v2/user/homes/" + str(home_id)) 503 if not home_response.get("success"): 504 raise RoborockException(home_response) 505 home_data = home_response.get("result") 506 if isinstance(home_data, dict): 507 return HomeData.from_dict(home_data) 508 else: 509 raise RoborockException("home_response result was an unexpected type") 510 511 async def get_home_data_v3(self, user_data: UserData) -> HomeData: 512 """This is the same as get_home_data, but uses a different endpoint and includes non-robotic vacuums.""" 513 if not self._home_data_limiter.try_acquire("home_data", blocking=False): 514 raise RoborockRateLimit("Reached maximum requests for home data. Please try again later.") 515 rriot = user_data.rriot 516 home_id = await self._get_home_id(user_data) 517 if rriot.r.a is None: 518 raise RoborockException("Missing field 'a' in rriot reference") 519 home_request = PreparedRequest( 520 rriot.r.a, 521 self.session, 522 { 523 "Authorization": _get_hawk_authentication(rriot, "/v3/user/homes/" + str(home_id)), 524 }, 525 ) 526 home_response = await home_request.request("get", "/v3/user/homes/" + str(home_id)) 527 if not home_response.get("success"): 528 raise RoborockException(home_response) 529 home_data = home_response.get("result") 530 if isinstance(home_data, dict): 531 return HomeData.from_dict(home_data) 532 raise RoborockException(f"home_response result was an unexpected type: {home_data}") 533 534 async def get_rooms(self, user_data: UserData, home_id: int | None = None) -> list[HomeDataRoom]: 535 rriot = user_data.rriot 536 if rriot is None: 537 raise RoborockException("rriot is none") 538 if home_id is None: 539 home_id = await self._get_home_id(user_data) 540 if rriot.r.a is None: 541 raise RoborockException("Missing field 'a' in rriot reference") 542 room_request = PreparedRequest( 543 rriot.r.a, 544 self.session, 545 { 546 "Authorization": _get_hawk_authentication(rriot, f"/user/homes/{home_id}/rooms"), 547 }, 548 ) 549 room_response = await room_request.request("get", f"/user/homes/{home_id}/rooms") 550 if not room_response.get("success"): 551 raise RoborockException(room_response) 552 rooms = room_response.get("result") 553 if isinstance(rooms, list): 554 output_list = [] 555 for room in rooms: 556 output_list.append(HomeDataRoom.from_dict(room)) 557 return output_list 558 else: 559 raise RoborockException("home_response result was an unexpected type") 560 561 async def get_scenes(self, user_data: UserData, device_id: str) -> list[HomeDataScene]: 562 rriot = user_data.rriot 563 if rriot is None: 564 raise RoborockException("rriot is none") 565 if rriot.r.a is None: 566 raise RoborockException("Missing field 'a' in rriot reference") 567 scenes_request = PreparedRequest( 568 rriot.r.a, 569 self.session, 570 { 571 "Authorization": _get_hawk_authentication(rriot, f"/user/scene/device/{str(device_id)}"), 572 }, 573 ) 574 scenes_response = await scenes_request.request("get", f"/user/scene/device/{str(device_id)}") 575 if not scenes_response.get("success"): 576 raise RoborockException(scenes_response) 577 scenes = scenes_response.get("result") 578 if isinstance(scenes, list): 579 return [HomeDataScene.from_dict(scene) for scene in scenes] 580 else: 581 raise RoborockException("scene_response result was an unexpected type") 582 583 async def execute_scene(self, user_data: UserData, scene_id: int) -> None: 584 rriot = user_data.rriot 585 if rriot is None: 586 raise RoborockException("rriot is none") 587 if rriot.r.a is None: 588 raise RoborockException("Missing field 'a' in rriot reference") 589 execute_scene_request = PreparedRequest( 590 rriot.r.a, 591 self.session, 592 { 593 "Authorization": _get_hawk_authentication(rriot, f"/user/scene/{str(scene_id)}/execute"), 594 }, 595 ) 596 execute_scene_response = await execute_scene_request.request("POST", f"/user/scene/{str(scene_id)}/execute") 597 if not execute_scene_response.get("success"): 598 raise RoborockException(execute_scene_response) 599 600 async def get_schedules(self, user_data: UserData, device_id: str) -> list[HomeDataSchedule]: 601 rriot = user_data.rriot 602 if rriot is None: 603 raise RoborockException("rriot is none") 604 if rriot.r.a is None: 605 raise RoborockException("Missing field 'a' in rriot reference") 606 schedules_request = PreparedRequest( 607 rriot.r.a, 608 self.session, 609 { 610 "Authorization": _get_hawk_authentication(rriot, f"/user/devices/{device_id}/jobs"), 611 }, 612 ) 613 schedules_response = await schedules_request.request("get", f"/user/devices/{str(device_id)}/jobs") 614 if not schedules_response.get("success"): 615 raise RoborockException(schedules_response) 616 schedules = schedules_response.get("result") 617 if isinstance(schedules, list): 618 return [HomeDataSchedule.from_dict(schedule) for schedule in schedules] 619 else: 620 raise RoborockException(f"schedule_response result was an unexpected type: {schedules}") 621 622 async def get_products(self, user_data: UserData) -> ProductResponse: 623 """Gets all products and their schemas, good for determining status codes and model numbers.""" 624 base_url = await self.base_url 625 header_clientid = self._get_header_client_id() 626 product_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) 627 product_response = await product_request.request( 628 "get", 629 "/api/v4/product", 630 headers={"Authorization": user_data.token}, 631 ) 632 if product_response is None: 633 raise RoborockException("home_id_response is None") 634 if product_response.get("code") != 200: 635 raise RoborockException(f"{product_response.get('msg')} - response code: {product_response.get('code')}") 636 result = product_response.get("data") 637 if isinstance(result, dict): 638 return ProductResponse.from_dict(result) 639 raise RoborockException("product result was an unexpected type") 640 641 async def download_code(self, user_data: UserData, product_id: int): 642 base_url = await self.base_url 643 header_clientid = self._get_header_client_id() 644 product_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) 645 request = {"apilevel": 99999, "productids": [product_id], "type": 2} 646 response = await product_request.request( 647 "post", 648 "/api/v1/appplugin", 649 json=request, 650 headers={"Authorization": user_data.token, "Content-Type": "application/json"}, 651 ) 652 return response["data"][0]["url"] 653 654 async def download_category_code(self, user_data: UserData): 655 base_url = await self.base_url 656 header_clientid = self._get_header_client_id() 657 product_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) 658 response = await product_request.request( 659 "get", 660 "api/v1/plugins?apiLevel=99999&type=2", 661 headers={ 662 "Authorization": user_data.token, 663 }, 664 ) 665 return {r["category"]: r["url"] for r in response["data"]["categoryPluginList"]}
67 def __init__( 68 self, username: str, base_url: str | None = None, session: aiohttp.ClientSession | None = None 69 ) -> None: 70 """Sample API Client.""" 71 self._username = username 72 self._base_url = base_url 73 self._device_identifier = secrets.token_urlsafe(16) 74 self.session = session 75 self._iot_login_info: IotLoginInfo | None = None 76 self._base_urls = BASE_URLS if base_url is None else [base_url]
Sample API Client.
135 async def nc_prepare(self, user_data: UserData, timezone: str) -> dict: 136 """This gets a few critical parameters for adding a device to your account.""" 137 if ( 138 user_data.rriot is None 139 or user_data.rriot.r is None 140 or user_data.rriot.u is None 141 or user_data.rriot.r.a is None 142 ): 143 raise RoborockException("Your userdata is missing critical attributes.") 144 base_url = user_data.rriot.r.a 145 prepare_request = PreparedRequest(base_url, self.session) 146 hid = await self._get_home_id(user_data) 147 148 data = FormData() 149 data.add_field("hid", hid) 150 data.add_field("tzid", timezone) 151 152 prepare_response = await prepare_request.request( 153 "post", 154 "/nc/prepare", 155 headers={ 156 "Authorization": _get_hawk_authentication( 157 user_data.rriot, "/nc/prepare", {"hid": hid, "tzid": timezone} 158 ), 159 }, 160 data=data, 161 ) 162 163 if prepare_response is None: 164 raise RoborockException("prepare_response is None") 165 if not prepare_response.get("success"): 166 raise RoborockException(f"{prepare_response.get('msg')} - response code: {prepare_response.get('code')}") 167 168 return prepare_response["result"]
This gets a few critical parameters for adding a device to your account.
170 async def add_device(self, user_data: UserData, s: str, t: str) -> dict: 171 """This will add a new device to your account 172 it is recommended to only use this during a pairing cycle with a device. 173 Please see here: https://github.com/Python-roborock/Roborockmitmproxy/blob/main/handshake_protocol.md 174 """ 175 if ( 176 user_data.rriot is None 177 or user_data.rriot.r is None 178 or user_data.rriot.u is None 179 or user_data.rriot.r.a is None 180 ): 181 raise RoborockException("Your userdata is missing critical attributes.") 182 base_url = user_data.rriot.r.a 183 add_device_request = PreparedRequest(base_url, self.session) 184 185 add_device_response = await add_device_request.request( 186 "GET", 187 "/user/devices/newadd", 188 headers={ 189 "Authorization": _get_hawk_authentication( 190 user_data.rriot, "/user/devices/newadd", params={"s": s, "t": t} 191 ), 192 }, 193 params={"s": s, "t": t}, 194 ) 195 196 if add_device_response is None: 197 raise RoborockException("add_device is None") 198 if not add_device_response.get("success"): 199 raise RoborockException( 200 f"{add_device_response.get('msg')} - response code: {add_device_response.get('code')}" 201 ) 202 203 return add_device_response["result"]
This will add a new device to your account it is recommended to only use this during a pairing cycle with a device. Please see here: https://github.com/Python-roborock/Roborockmitmproxy/blob/main/handshake_protocol.md
205 async def request_code(self) -> None: 206 if not await self._login_limiter.try_acquire_async("login", blocking=True, timeout=1): 207 raise RoborockRateLimit("Reached maximum requests for login. Please try again later.") 208 base_url = await self.base_url 209 header_clientid = self._get_header_client_id() 210 code_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) 211 212 code_response = await code_request.request( 213 "post", 214 "/api/v1/sendEmailCode", 215 params={ 216 "username": self._username, 217 "type": "auth", 218 }, 219 ) 220 if code_response is None: 221 raise RoborockException("Failed to get a response from send email code") 222 response_code = code_response.get("code") 223 if response_code != 200: 224 _LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response) 225 if response_code == 2008: 226 raise RoborockAccountDoesNotExist("Account does not exist - check your login and try again.") 227 elif response_code == 9002: 228 raise RoborockTooFrequentCodeRequests("You have attempted to request too many codes. Try again later") 229 else: 230 raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}")
232 async def request_code_v4(self) -> None: 233 """Request a code using the v4 endpoint.""" 234 if await self.country_code is None or await self.country is None: 235 _LOGGER.info("No country code or country found, trying old version of request code.") 236 return await self.request_code() 237 if not await self._login_limiter.try_acquire_async("login", blocking=True, timeout=1): 238 raise RoborockRateLimit("Reached maximum requests for login. Please try again later.") 239 base_url = await self.base_url 240 header_clientid = self._get_header_client_id() 241 code_request = PreparedRequest( 242 base_url, 243 self.session, 244 { 245 "header_clientid": header_clientid, 246 "Content-Type": "application/x-www-form-urlencoded", 247 "header_clientlang": "en", 248 }, 249 ) 250 251 code_response = await code_request.request( 252 "post", 253 "/api/v4/email/code/send", 254 data={"email": self._username, "type": "login", "platform": ""}, 255 ) 256 if code_response is None: 257 raise RoborockException("Failed to get a response from send email code") 258 response_code = code_response.get("code") 259 if response_code != 200: 260 _LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response) 261 if response_code == 2008: 262 raise RoborockAccountDoesNotExist("Account does not exist - check your login and try again.") 263 elif response_code == 9002: 264 raise RoborockTooFrequentCodeRequests("You have attempted to request too many codes. Try again later") 265 elif response_code == 3030 and len(self._base_urls) > 1: 266 self._base_urls = self._base_urls[1:] 267 self._iot_login_info = None 268 return await self.request_code_v4() 269 else: 270 raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}")
Request a code using the v4 endpoint.
294 async def code_login_v4( 295 self, code: int | str, country: str | None = None, country_code: int | None = None 296 ) -> UserData: 297 """ 298 Login via code authentication. 299 :param code: The code from the email. 300 :param country: The two-character representation of the country, i.e. "US" 301 :param country_code: the country phone number code i.e. 1 for US. 302 """ 303 base_url = await self.base_url 304 if country is None: 305 country = await self.country 306 if country_code is None: 307 country_code = await self.country_code 308 if country_code is None or country is None: 309 _LOGGER.info("No country code or country found, trying old version of code login.") 310 return await self.code_login(code) 311 header_clientid = self._get_header_client_id() 312 x_mercy_ks = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16)) 313 x_mercy_k = await self._sign_key_v3(x_mercy_ks) 314 login_request = PreparedRequest( 315 base_url, 316 self.session, 317 { 318 "header_clientid": header_clientid, 319 "x-mercy-ks": x_mercy_ks, 320 "x-mercy-k": x_mercy_k, 321 "Content-Type": "application/x-www-form-urlencoded", 322 "header_clientlang": "en", 323 "header_appversion": "4.54.02", 324 "header_phonesystem": "iOS", 325 "header_phonemodel": "iPhone16,1", 326 }, 327 ) 328 login_response = await login_request.request( 329 "post", 330 "/api/v4/auth/email/login/code", 331 data={ 332 "country": country, 333 "countryCode": country_code, 334 "email": self._username, 335 "code": code, 336 # Major and minor version are the user agreement version, we will need to see if this needs to be 337 # dynamic https://usiot.roborock.com/api/v3/app/agreement/latest?country=US 338 "majorVersion": 14, 339 "minorVersion": 0, 340 }, 341 ) 342 if login_response is None: 343 raise RoborockException("Login request response is None") 344 response_code = login_response.get("code") 345 if response_code != 200: 346 _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response) 347 if response_code == 2018: 348 raise RoborockInvalidCode("Invalid code - check your code and try again.") 349 if response_code == 3009: 350 raise RoborockNoUserAgreement("You must accept the user agreement in the Roborock app to continue.") 351 if response_code == 3006: 352 raise RoborockInvalidUserAgreement( 353 "User agreement must be accepted again - or you are attempting to use the Mi Home app account." 354 ) 355 if response_code == 3039: 356 raise RoborockAccountDoesNotExist( 357 "This account does not exist - please ensure that you selected the right region and email." 358 ) 359 raise RoborockException(f"{login_response.get('msg')} - response code: {response_code}") 360 user_data = login_response.get("data") 361 if not isinstance(user_data, dict): 362 raise RoborockException("Got unexpected data type for user_data") 363 return UserData.from_dict(user_data)
Login via code authentication.
Parameters
- code: The code from the email.
- country: The two-character representation of the country, i.e. "US"
- country_code: the country phone number code i.e. 1 for US.
365 async def pass_login(self, password: str) -> UserData: 366 if not await self._login_limiter.try_acquire_async("login", blocking=True, timeout=1): 367 raise RoborockRateLimit("Reached maximum requests for login. Please try again later.") 368 base_url = await self.base_url 369 header_clientid = self._get_header_client_id() 370 371 login_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) 372 login_response = await login_request.request( 373 "post", 374 "/api/v1/login", 375 params={ 376 "username": self._username, 377 "password": password, 378 "needtwostepauth": "false", 379 }, 380 ) 381 if login_response is None: 382 raise RoborockException("Login response is none") 383 if login_response.get("code") != 200: 384 _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response) 385 raise RoborockException(f"{login_response.get('msg')} - response code: {login_response.get('code')}") 386 user_data = login_response.get("data") 387 if not isinstance(user_data, dict): 388 raise RoborockException("Got unexpected data type for user_data") 389 return UserData.from_dict(user_data)
391 async def pass_login_v3(self, password: str) -> UserData: 392 """Seemingly it follows the format below, but password is encrypted in some manner. 393 # login_response = await login_request.request( 394 # "post", 395 # "/api/v3/auth/email/login", 396 # params={ 397 # "email": self._username, 398 # "password": password, 399 # "twoStep": 1, 400 # "version": 0 401 # }, 402 # ) 403 """ 404 raise NotImplementedError("Pass_login_v3 has not yet been implemented")
Seemingly it follows the format below, but password is encrypted in some manner.
login_response = await login_request.request(
"post",
"/api/v3/auth/email/login",
params={
"email": self._username,
"password": password,
"twoStep": 1,
"version": 0
},
)
406 async def code_login(self, code: int | str) -> UserData: 407 base_url = await self.base_url 408 header_clientid = self._get_header_client_id() 409 410 login_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) 411 login_response = await login_request.request( 412 "post", 413 "/api/v1/loginWithCode", 414 params={ 415 "username": self._username, 416 "verifycode": code, 417 "verifycodetype": "AUTH_EMAIL_CODE", 418 }, 419 ) 420 if login_response is None: 421 raise RoborockException("Login request response is None") 422 response_code = login_response.get("code") 423 if response_code != 200: 424 _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response) 425 if response_code == 2018: 426 raise RoborockInvalidCode("Invalid code - check your code and try again.") 427 if response_code == 3009: 428 raise RoborockNoUserAgreement("You must accept the user agreement in the Roborock app to continue.") 429 if response_code == 3006: 430 raise RoborockInvalidUserAgreement( 431 "User agreement must be accepted again - or you are attempting to use the Mi Home app account." 432 ) 433 raise RoborockException(f"{login_response.get('msg')} - response code: {response_code}") 434 user_data = login_response.get("data") 435 if not isinstance(user_data, dict): 436 raise RoborockException("Got unexpected data type for user_data") 437 return UserData.from_dict(user_data)
460 async def get_home_data(self, user_data: UserData) -> HomeData: 461 if not self._home_data_limiter.try_acquire("home_data", blocking=False): 462 raise RoborockRateLimit("Reached maximum requests for home data. Please try again later.") 463 rriot = user_data.rriot 464 if rriot is None: 465 raise RoborockException("rriot is none") 466 home_id = await self._get_home_id(user_data) 467 if rriot.r.a is None: 468 raise RoborockException("Missing field 'a' in rriot reference") 469 home_request = PreparedRequest( 470 rriot.r.a, 471 self.session, 472 { 473 "Authorization": _get_hawk_authentication(rriot, f"/user/homes/{str(home_id)}"), 474 }, 475 ) 476 home_response = await home_request.request("get", "/user/homes/" + str(home_id)) 477 if not home_response.get("success"): 478 raise RoborockException(home_response) 479 home_data = home_response.get("result") 480 if isinstance(home_data, dict): 481 return HomeData.from_dict(home_data) 482 else: 483 raise RoborockException("home_response result was an unexpected type")
485 async def get_home_data_v2(self, user_data: UserData) -> HomeData: 486 """This is the same as get_home_data, but uses a different endpoint and includes non-robotic vacuums.""" 487 if not self._home_data_limiter.try_acquire("home_data", blocking=False): 488 raise RoborockRateLimit("Reached maximum requests for home data. Please try again later.") 489 rriot = user_data.rriot 490 if rriot is None: 491 raise RoborockException("rriot is none") 492 home_id = await self._get_home_id(user_data) 493 if rriot.r.a is None: 494 raise RoborockException("Missing field 'a' in rriot reference") 495 home_request = PreparedRequest( 496 rriot.r.a, 497 self.session, 498 { 499 "Authorization": _get_hawk_authentication(rriot, "/v2/user/homes/" + str(home_id)), 500 }, 501 ) 502 home_response = await home_request.request("get", "/v2/user/homes/" + str(home_id)) 503 if not home_response.get("success"): 504 raise RoborockException(home_response) 505 home_data = home_response.get("result") 506 if isinstance(home_data, dict): 507 return HomeData.from_dict(home_data) 508 else: 509 raise RoborockException("home_response result was an unexpected type")
This is the same as get_home_data, but uses a different endpoint and includes non-robotic vacuums.
511 async def get_home_data_v3(self, user_data: UserData) -> HomeData: 512 """This is the same as get_home_data, but uses a different endpoint and includes non-robotic vacuums.""" 513 if not self._home_data_limiter.try_acquire("home_data", blocking=False): 514 raise RoborockRateLimit("Reached maximum requests for home data. Please try again later.") 515 rriot = user_data.rriot 516 home_id = await self._get_home_id(user_data) 517 if rriot.r.a is None: 518 raise RoborockException("Missing field 'a' in rriot reference") 519 home_request = PreparedRequest( 520 rriot.r.a, 521 self.session, 522 { 523 "Authorization": _get_hawk_authentication(rriot, "/v3/user/homes/" + str(home_id)), 524 }, 525 ) 526 home_response = await home_request.request("get", "/v3/user/homes/" + str(home_id)) 527 if not home_response.get("success"): 528 raise RoborockException(home_response) 529 home_data = home_response.get("result") 530 if isinstance(home_data, dict): 531 return HomeData.from_dict(home_data) 532 raise RoborockException(f"home_response result was an unexpected type: {home_data}")
This is the same as get_home_data, but uses a different endpoint and includes non-robotic vacuums.
534 async def get_rooms(self, user_data: UserData, home_id: int | None = None) -> list[HomeDataRoom]: 535 rriot = user_data.rriot 536 if rriot is None: 537 raise RoborockException("rriot is none") 538 if home_id is None: 539 home_id = await self._get_home_id(user_data) 540 if rriot.r.a is None: 541 raise RoborockException("Missing field 'a' in rriot reference") 542 room_request = PreparedRequest( 543 rriot.r.a, 544 self.session, 545 { 546 "Authorization": _get_hawk_authentication(rriot, f"/user/homes/{home_id}/rooms"), 547 }, 548 ) 549 room_response = await room_request.request("get", f"/user/homes/{home_id}/rooms") 550 if not room_response.get("success"): 551 raise RoborockException(room_response) 552 rooms = room_response.get("result") 553 if isinstance(rooms, list): 554 output_list = [] 555 for room in rooms: 556 output_list.append(HomeDataRoom.from_dict(room)) 557 return output_list 558 else: 559 raise RoborockException("home_response result was an unexpected type")
561 async def get_scenes(self, user_data: UserData, device_id: str) -> list[HomeDataScene]: 562 rriot = user_data.rriot 563 if rriot is None: 564 raise RoborockException("rriot is none") 565 if rriot.r.a is None: 566 raise RoborockException("Missing field 'a' in rriot reference") 567 scenes_request = PreparedRequest( 568 rriot.r.a, 569 self.session, 570 { 571 "Authorization": _get_hawk_authentication(rriot, f"/user/scene/device/{str(device_id)}"), 572 }, 573 ) 574 scenes_response = await scenes_request.request("get", f"/user/scene/device/{str(device_id)}") 575 if not scenes_response.get("success"): 576 raise RoborockException(scenes_response) 577 scenes = scenes_response.get("result") 578 if isinstance(scenes, list): 579 return [HomeDataScene.from_dict(scene) for scene in scenes] 580 else: 581 raise RoborockException("scene_response result was an unexpected type")
583 async def execute_scene(self, user_data: UserData, scene_id: int) -> None: 584 rriot = user_data.rriot 585 if rriot is None: 586 raise RoborockException("rriot is none") 587 if rriot.r.a is None: 588 raise RoborockException("Missing field 'a' in rriot reference") 589 execute_scene_request = PreparedRequest( 590 rriot.r.a, 591 self.session, 592 { 593 "Authorization": _get_hawk_authentication(rriot, f"/user/scene/{str(scene_id)}/execute"), 594 }, 595 ) 596 execute_scene_response = await execute_scene_request.request("POST", f"/user/scene/{str(scene_id)}/execute") 597 if not execute_scene_response.get("success"): 598 raise RoborockException(execute_scene_response)
600 async def get_schedules(self, user_data: UserData, device_id: str) -> list[HomeDataSchedule]: 601 rriot = user_data.rriot 602 if rriot is None: 603 raise RoborockException("rriot is none") 604 if rriot.r.a is None: 605 raise RoborockException("Missing field 'a' in rriot reference") 606 schedules_request = PreparedRequest( 607 rriot.r.a, 608 self.session, 609 { 610 "Authorization": _get_hawk_authentication(rriot, f"/user/devices/{device_id}/jobs"), 611 }, 612 ) 613 schedules_response = await schedules_request.request("get", f"/user/devices/{str(device_id)}/jobs") 614 if not schedules_response.get("success"): 615 raise RoborockException(schedules_response) 616 schedules = schedules_response.get("result") 617 if isinstance(schedules, list): 618 return [HomeDataSchedule.from_dict(schedule) for schedule in schedules] 619 else: 620 raise RoborockException(f"schedule_response result was an unexpected type: {schedules}")
622 async def get_products(self, user_data: UserData) -> ProductResponse: 623 """Gets all products and their schemas, good for determining status codes and model numbers.""" 624 base_url = await self.base_url 625 header_clientid = self._get_header_client_id() 626 product_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) 627 product_response = await product_request.request( 628 "get", 629 "/api/v4/product", 630 headers={"Authorization": user_data.token}, 631 ) 632 if product_response is None: 633 raise RoborockException("home_id_response is None") 634 if product_response.get("code") != 200: 635 raise RoborockException(f"{product_response.get('msg')} - response code: {product_response.get('code')}") 636 result = product_response.get("data") 637 if isinstance(result, dict): 638 return ProductResponse.from_dict(result) 639 raise RoborockException("product result was an unexpected type")
Gets all products and their schemas, good for determining status codes and model numbers.
641 async def download_code(self, user_data: UserData, product_id: int): 642 base_url = await self.base_url 643 header_clientid = self._get_header_client_id() 644 product_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) 645 request = {"apilevel": 99999, "productids": [product_id], "type": 2} 646 response = await product_request.request( 647 "post", 648 "/api/v1/appplugin", 649 json=request, 650 headers={"Authorization": user_data.token, "Content-Type": "application/json"}, 651 ) 652 return response["data"][0]["url"]
654 async def download_category_code(self, user_data: UserData): 655 base_url = await self.base_url 656 header_clientid = self._get_header_client_id() 657 product_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) 658 response = await product_request.request( 659 "get", 660 "api/v1/plugins?apiLevel=99999&type=2", 661 headers={ 662 "Authorization": user_data.token, 663 }, 664 ) 665 return {r["category"]: r["url"] for r in response["data"]["categoryPluginList"]}
668class PreparedRequest: 669 def __init__( 670 self, base_url: str, session: aiohttp.ClientSession | None = None, base_headers: dict | None = None 671 ) -> None: 672 self.base_url = base_url 673 self.base_headers = base_headers or {} 674 self.session = session 675 676 async def request(self, method: str, url: str, params=None, data=None, headers=None, json=None) -> dict: 677 _url = "/".join(s.strip("/") for s in [self.base_url, url]) 678 _headers = {**self.base_headers, **(headers or {})} 679 close_session = self.session is None 680 session = self.session if self.session is not None else aiohttp.ClientSession() 681 try: 682 async with session.request(method, _url, params=params, data=data, headers=_headers, json=json) as resp: 683 return await resp.json() 684 except ContentTypeError as err: 685 """If we get an error, lets log everything for debugging.""" 686 try: 687 resp_json = await resp.json(content_type=None) 688 _LOGGER.info("Resp: %s", resp_json) 689 except ContentTypeError as err_2: 690 _LOGGER.info(err_2) 691 resp_raw = await resp.read() 692 _LOGGER.info("Resp raw: %s", resp_raw) 693 # Still raise the err so that it's clear it failed. 694 raise err 695 finally: 696 if close_session: 697 await session.close()
676 async def request(self, method: str, url: str, params=None, data=None, headers=None, json=None) -> dict: 677 _url = "/".join(s.strip("/") for s in [self.base_url, url]) 678 _headers = {**self.base_headers, **(headers or {})} 679 close_session = self.session is None 680 session = self.session if self.session is not None else aiohttp.ClientSession() 681 try: 682 async with session.request(method, _url, params=params, data=data, headers=_headers, json=json) as resp: 683 return await resp.json() 684 except ContentTypeError as err: 685 """If we get an error, lets log everything for debugging.""" 686 try: 687 resp_json = await resp.json(content_type=None) 688 _LOGGER.info("Resp: %s", resp_json) 689 except ContentTypeError as err_2: 690 _LOGGER.info(err_2) 691 resp_raw = await resp.read() 692 _LOGGER.info("Resp raw: %s", resp_raw) 693 # Still raise the err so that it's clear it failed. 694 raise err 695 finally: 696 if close_session: 697 await session.close()
733class UserWebApiClient: 734 """Wrapper around RoborockApiClient to provide information for a specific user. 735 736 This binds a RoborockApiClient to a specific user context with the 737 provided UserData. This allows for easier access to user-specific data, 738 to avoid needing to pass UserData around and mock out the web API. 739 """ 740 741 def __init__(self, web_api: RoborockApiClient, user_data: UserData) -> None: 742 """Initialize the wrapper with the API client and user data.""" 743 self._web_api = web_api 744 self._user_data = user_data 745 746 async def get_home_data(self) -> HomeData: 747 """Fetch home data using the API client.""" 748 return await self._web_api.get_home_data_v3(self._user_data) 749 750 async def get_routines(self, device_id: str) -> list[HomeDataScene]: 751 """Fetch routines (scenes) for a specific device.""" 752 return await self._web_api.get_scenes(self._user_data, device_id) 753 754 async def get_rooms(self) -> list[HomeDataRoom]: 755 """Fetch rooms using the API client.""" 756 return await self._web_api.get_rooms(self._user_data) 757 758 async def execute_routine(self, scene_id: int) -> None: 759 """Execute a specific routine (scene) by its ID.""" 760 await self._web_api.execute_scene(self._user_data, scene_id)
Wrapper around RoborockApiClient to provide information for a specific user.
This binds a RoborockApiClient to a specific user context with the provided UserData. This allows for easier access to user-specific data, to avoid needing to pass UserData around and mock out the web API.
741 def __init__(self, web_api: RoborockApiClient, user_data: UserData) -> None: 742 """Initialize the wrapper with the API client and user data.""" 743 self._web_api = web_api 744 self._user_data = user_data
Initialize the wrapper with the API client and user data.
746 async def get_home_data(self) -> HomeData: 747 """Fetch home data using the API client.""" 748 return await self._web_api.get_home_data_v3(self._user_data)
Fetch home data using the API client.
750 async def get_routines(self, device_id: str) -> list[HomeDataScene]: 751 """Fetch routines (scenes) for a specific device.""" 752 return await self._web_api.get_scenes(self._user_data, device_id)
Fetch routines (scenes) for a specific device.