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)
BASE_URLS = ['https://usiot.roborock.com', 'https://euiot.roborock.com', 'https://cniot.roborock.com', 'https://ruiot.roborock.com']
@dataclass
class IotLoginInfo:
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.

IotLoginInfo(base_url: str, country_code: str, country: str)
base_url: str
country_code: str
country: str
class RoborockApiClient:
 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"]}
RoborockApiClient( username: str, base_url: str | None = None, session: aiohttp.client.ClientSession | None = None)
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.

session
base_url
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
country
121    @property
122    async def country(self):
123        return (await self._get_iot_login_info()).country
country_code
125    @property
126    async def country_code(self):
127        return (await self._get_iot_login_info()).country_code
async def nc_prepare( self, user_data: roborock.data.containers.UserData, timezone: str) -> dict:
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.

async def add_device( self, user_data: roborock.data.containers.UserData, s: str, t: str) -> dict:
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

async def request_code(self) -> None:
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')}")
async def request_code_v4(self) -> None:
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.

async def code_login_v4( self, code: int | str, country: str | None = None, country_code: int | None = None) -> roborock.data.containers.UserData:
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.
async def pass_login(self, password: str) -> roborock.data.containers.UserData:
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)
async def pass_login_v3(self, password: str) -> roborock.data.containers.UserData:
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

},

)

async def code_login(self, code: int | str) -> roborock.data.containers.UserData:
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)
async def get_home_data( self, user_data: roborock.data.containers.UserData) -> roborock.data.containers.HomeData:
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")
async def get_home_data_v2( self, user_data: roborock.data.containers.UserData) -> roborock.data.containers.HomeData:
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.

async def get_home_data_v3( self, user_data: roborock.data.containers.UserData) -> roborock.data.containers.HomeData:
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.

async def get_rooms( self, user_data: roborock.data.containers.UserData, home_id: int | None = None) -> list[roborock.data.containers.HomeDataRoom]:
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")
async def get_scenes( self, user_data: roborock.data.containers.UserData, device_id: str) -> list[roborock.data.containers.HomeDataScene]:
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")
async def execute_scene( self, user_data: roborock.data.containers.UserData, scene_id: int) -> None:
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)
async def get_schedules( self, user_data: roborock.data.containers.UserData, device_id: str) -> list[roborock.data.containers.HomeDataSchedule]:
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}")
async def get_products( self, user_data: roborock.data.containers.UserData) -> roborock.data.containers.ProductResponse:
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.

async def download_code(self, user_data: roborock.data.containers.UserData, product_id: int):
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"]
async def download_category_code(self, user_data: roborock.data.containers.UserData):
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"]}
class PreparedRequest:
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()
PreparedRequest( base_url: str, session: aiohttp.client.ClientSession | None = None, base_headers: dict | None = None)
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
base_url
base_headers
session
async def request( self, method: str, url: str, params=None, data=None, headers=None, json=None) -> dict:
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()
class UserWebApiClient:
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.

UserWebApiClient( web_api: RoborockApiClient, user_data: roborock.data.containers.UserData)
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.

async def get_home_data(self) -> roborock.data.containers.HomeData:
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.

async def get_routines(self, device_id: str) -> list[roborock.data.containers.HomeDataScene]:
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.

async def get_rooms(self) -> list[roborock.data.containers.HomeDataRoom]:
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)

Fetch rooms using the API client.

async def execute_routine(self, scene_id: int) -> None:
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)

Execute a specific routine (scene) by its ID.