roborock.web_api

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

Sample API Client.

session
base_url
117    @property
118    async def base_url(self):
119        if self._base_url is not None:
120            return self._base_url
121        return (await self._get_iot_login_info()).base_url
country
123    @property
124    async def country(self):
125        return (await self._get_iot_login_info()).country
country_code
127    @property
128    async def country_code(self):
129        return (await self._get_iot_login_info()).country_code
async def nc_prepare( self, user_data: roborock.data.containers.UserData, timezone: str) -> dict:
137    async def nc_prepare(self, user_data: UserData, timezone: str) -> dict:
138        """This gets a few critical parameters for adding a device to your account."""
139        if (
140            user_data.rriot is None
141            or user_data.rriot.r is None
142            or user_data.rriot.u is None
143            or user_data.rriot.r.a is None
144        ):
145            raise RoborockException("Your userdata is missing critical attributes.")
146        base_url = user_data.rriot.r.a
147        prepare_request = PreparedRequest(base_url, self.session)
148        hid = await self._get_home_id(user_data)
149
150        data = FormData()
151        data.add_field("hid", hid)
152        data.add_field("tzid", timezone)
153
154        prepare_response = await prepare_request.request(
155            "post",
156            "/nc/prepare",
157            headers={
158                "Authorization": _get_hawk_authentication(
159                    user_data.rriot, "/nc/prepare", {"hid": hid, "tzid": timezone}
160                ),
161            },
162            data=data,
163        )
164
165        if prepare_response is None:
166            raise RoborockException("prepare_response is None")
167        if not prepare_response.get("success"):
168            raise RoborockException(f"{prepare_response.get('msg')} - response code: {prepare_response.get('code')}")
169
170        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:
172    async def add_device(self, user_data: UserData, s: str, t: str) -> dict:
173        """This will add a new device to your account
174        it is recommended to only use this during a pairing cycle with a device.
175        Please see here: https://github.com/Python-roborock/Roborockmitmproxy/blob/main/handshake_protocol.md
176        """
177        if (
178            user_data.rriot is None
179            or user_data.rriot.r is None
180            or user_data.rriot.u is None
181            or user_data.rriot.r.a is None
182        ):
183            raise RoborockException("Your userdata is missing critical attributes.")
184        base_url = user_data.rriot.r.a
185        add_device_request = PreparedRequest(base_url, self.session)
186
187        add_device_response = await add_device_request.request(
188            "GET",
189            "/user/devices/newadd",
190            headers={
191                "Authorization": _get_hawk_authentication(
192                    user_data.rriot, "/user/devices/newadd", params={"s": s, "t": t}
193                ),
194            },
195            params={"s": s, "t": t},
196        )
197
198        if add_device_response is None:
199            raise RoborockException("add_device is None")
200        if not add_device_response.get("success"):
201            raise RoborockException(
202                f"{add_device_response.get('msg')} - response code: {add_device_response.get('code')}"
203            )
204
205        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:
207    async def request_code(self) -> None:
208        try:
209            await self._login_limiter.try_acquire_async("login")
210        except BucketFullException as ex:
211            _LOGGER.info(ex.meta_info)
212            raise RoborockRateLimit("Reached maximum requests for login. Please try again later.") from ex
213        base_url = await self.base_url
214        header_clientid = self._get_header_client_id()
215        code_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
216
217        code_response = await code_request.request(
218            "post",
219            "/api/v1/sendEmailCode",
220            params={
221                "username": self._username,
222                "type": "auth",
223            },
224        )
225        if code_response is None:
226            raise RoborockException("Failed to get a response from send email code")
227        response_code = code_response.get("code")
228        if response_code != 200:
229            _LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response)
230            if response_code == 2008:
231                raise RoborockAccountDoesNotExist("Account does not exist - check your login and try again.")
232            elif response_code == 9002:
233                raise RoborockTooFrequentCodeRequests("You have attempted to request too many codes. Try again later")
234            else:
235                raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}")
async def request_code_v4(self) -> None:
237    async def request_code_v4(self) -> None:
238        """Request a code using the v4 endpoint."""
239        if await self.country_code is None or await self.country is None:
240            _LOGGER.info("No country code or country found, trying old version of request code.")
241            return await self.request_code()
242        try:
243            await self._login_limiter.try_acquire_async("login")
244        except BucketFullException as ex:
245            _LOGGER.info(ex.meta_info)
246            raise RoborockRateLimit("Reached maximum requests for login. Please try again later.") from ex
247        base_url = await self.base_url
248        header_clientid = self._get_header_client_id()
249        code_request = PreparedRequest(
250            base_url,
251            self.session,
252            {
253                "header_clientid": header_clientid,
254                "Content-Type": "application/x-www-form-urlencoded",
255                "header_clientlang": "en",
256            },
257        )
258
259        code_response = await code_request.request(
260            "post",
261            "/api/v4/email/code/send",
262            data={"email": self._username, "type": "login", "platform": ""},
263        )
264        if code_response is None:
265            raise RoborockException("Failed to get a response from send email code")
266        response_code = code_response.get("code")
267        if response_code != 200:
268            _LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response)
269            if response_code == 2008:
270                raise RoborockAccountDoesNotExist("Account does not exist - check your login and try again.")
271            elif response_code == 9002:
272                raise RoborockTooFrequentCodeRequests("You have attempted to request too many codes. Try again later")
273            elif response_code == 3030 and len(self._base_urls) > 1:
274                self._base_urls = self._base_urls[1:]
275                self._iot_login_info = None
276                return await self.request_code_v4()
277            else:
278                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:
302    async def code_login_v4(
303        self, code: int | str, country: str | None = None, country_code: int | None = None
304    ) -> UserData:
305        """
306        Login via code authentication.
307        :param code: The code from the email.
308        :param country: The two-character representation of the country, i.e. "US"
309        :param country_code: the country phone number code i.e. 1 for US.
310        """
311        base_url = await self.base_url
312        if country is None:
313            country = await self.country
314        if country_code is None:
315            country_code = await self.country_code
316        if country_code is None or country is None:
317            _LOGGER.info("No country code or country found, trying old version of code login.")
318            return await self.code_login(code)
319        header_clientid = self._get_header_client_id()
320        x_mercy_ks = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
321        x_mercy_k = await self._sign_key_v3(x_mercy_ks)
322        login_request = PreparedRequest(
323            base_url,
324            self.session,
325            {
326                "header_clientid": header_clientid,
327                "x-mercy-ks": x_mercy_ks,
328                "x-mercy-k": x_mercy_k,
329                "Content-Type": "application/x-www-form-urlencoded",
330                "header_clientlang": "en",
331                "header_appversion": "4.54.02",
332                "header_phonesystem": "iOS",
333                "header_phonemodel": "iPhone16,1",
334            },
335        )
336        login_response = await login_request.request(
337            "post",
338            "/api/v4/auth/email/login/code",
339            data={
340                "country": country,
341                "countryCode": country_code,
342                "email": self._username,
343                "code": code,
344                # Major and minor version are the user agreement version, we will need to see if this needs to be
345                # dynamic https://usiot.roborock.com/api/v3/app/agreement/latest?country=US
346                "majorVersion": 14,
347                "minorVersion": 0,
348            },
349        )
350        if login_response is None:
351            raise RoborockException("Login request response is None")
352        response_code = login_response.get("code")
353        if response_code != 200:
354            _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response)
355            if response_code == 2018:
356                raise RoborockInvalidCode("Invalid code - check your code and try again.")
357            if response_code == 3009:
358                raise RoborockNoUserAgreement("You must accept the user agreement in the Roborock app to continue.")
359            if response_code == 3006:
360                raise RoborockInvalidUserAgreement(
361                    "User agreement must be accepted again - or you are attempting to use the Mi Home app account."
362                )
363            if response_code == 3039:
364                raise RoborockAccountDoesNotExist(
365                    "This account does not exist - please ensure that you selected the right region and email."
366                )
367            raise RoborockException(f"{login_response.get('msg')} - response code: {response_code}")
368        user_data = login_response.get("data")
369        if not isinstance(user_data, dict):
370            raise RoborockException("Got unexpected data type for user_data")
371        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:
373    async def pass_login(self, password: str) -> UserData:
374        try:
375            await self._login_limiter.try_acquire_async("login")
376        except BucketFullException as ex:
377            _LOGGER.info(ex.meta_info)
378            raise RoborockRateLimit("Reached maximum requests for login. Please try again later.") from ex
379        base_url = await self.base_url
380        header_clientid = self._get_header_client_id()
381
382        login_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
383        login_response = await login_request.request(
384            "post",
385            "/api/v1/login",
386            params={
387                "username": self._username,
388                "password": password,
389                "needtwostepauth": "false",
390            },
391        )
392        if login_response is None:
393            raise RoborockException("Login response is none")
394        if login_response.get("code") != 200:
395            _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response)
396            raise RoborockException(f"{login_response.get('msg')} - response code: {login_response.get('code')}")
397        user_data = login_response.get("data")
398        if not isinstance(user_data, dict):
399            raise RoborockException("Got unexpected data type for user_data")
400        return UserData.from_dict(user_data)
async def pass_login_v3(self, password: str) -> roborock.data.containers.UserData:
402    async def pass_login_v3(self, password: str) -> UserData:
403        """Seemingly it follows the format below, but password is encrypted in some manner.
404        # login_response = await login_request.request(
405        #     "post",
406        #     "/api/v3/auth/email/login",
407        #     params={
408        #         "email": self._username,
409        #         "password": password,
410        #         "twoStep": 1,
411        #         "version": 0
412        #     },
413        # )
414        """
415        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:
417    async def code_login(self, code: int | str) -> UserData:
418        base_url = await self.base_url
419        header_clientid = self._get_header_client_id()
420
421        login_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
422        login_response = await login_request.request(
423            "post",
424            "/api/v1/loginWithCode",
425            params={
426                "username": self._username,
427                "verifycode": code,
428                "verifycodetype": "AUTH_EMAIL_CODE",
429            },
430        )
431        if login_response is None:
432            raise RoborockException("Login request response is None")
433        response_code = login_response.get("code")
434        if response_code != 200:
435            _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response)
436            if response_code == 2018:
437                raise RoborockInvalidCode("Invalid code - check your code and try again.")
438            if response_code == 3009:
439                raise RoborockNoUserAgreement("You must accept the user agreement in the Roborock app to continue.")
440            if response_code == 3006:
441                raise RoborockInvalidUserAgreement(
442                    "User agreement must be accepted again - or you are attempting to use the Mi Home app account."
443                )
444            raise RoborockException(f"{login_response.get('msg')} - response code: {response_code}")
445        user_data = login_response.get("data")
446        if not isinstance(user_data, dict):
447            raise RoborockException("Got unexpected data type for user_data")
448        return UserData.from_dict(user_data)
async def get_home_data( self, user_data: roborock.data.containers.UserData) -> roborock.data.containers.HomeData:
471    async def get_home_data(self, user_data: UserData) -> HomeData:
472        try:
473            self._home_data_limiter.try_acquire("home_data")
474        except BucketFullException as ex:
475            _LOGGER.info(ex.meta_info)
476            raise RoborockRateLimit("Reached maximum requests for home data. Please try again later.") from ex
477        rriot = user_data.rriot
478        if rriot is None:
479            raise RoborockException("rriot is none")
480        home_id = await self._get_home_id(user_data)
481        if rriot.r.a is None:
482            raise RoborockException("Missing field 'a' in rriot reference")
483        home_request = PreparedRequest(
484            rriot.r.a,
485            self.session,
486            {
487                "Authorization": _get_hawk_authentication(rriot, f"/user/homes/{str(home_id)}"),
488            },
489        )
490        home_response = await home_request.request("get", "/user/homes/" + str(home_id))
491        if not home_response.get("success"):
492            raise RoborockException(home_response)
493        home_data = home_response.get("result")
494        if isinstance(home_data, dict):
495            return HomeData.from_dict(home_data)
496        else:
497            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:
499    async def get_home_data_v2(self, user_data: UserData) -> HomeData:
500        """This is the same as get_home_data, but uses a different endpoint and includes non-robotic vacuums."""
501        try:
502            self._home_data_limiter.try_acquire("home_data")
503        except BucketFullException as ex:
504            _LOGGER.info(ex.meta_info)
505            raise RoborockRateLimit("Reached maximum requests for home data. Please try again later.") from ex
506        rriot = user_data.rriot
507        if rriot is None:
508            raise RoborockException("rriot is none")
509        home_id = await self._get_home_id(user_data)
510        if rriot.r.a is None:
511            raise RoborockException("Missing field 'a' in rriot reference")
512        home_request = PreparedRequest(
513            rriot.r.a,
514            self.session,
515            {
516                "Authorization": _get_hawk_authentication(rriot, "/v2/user/homes/" + str(home_id)),
517            },
518        )
519        home_response = await home_request.request("get", "/v2/user/homes/" + str(home_id))
520        if not home_response.get("success"):
521            raise RoborockException(home_response)
522        home_data = home_response.get("result")
523        if isinstance(home_data, dict):
524            return HomeData.from_dict(home_data)
525        else:
526            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:
528    async def get_home_data_v3(self, user_data: UserData) -> HomeData:
529        """This is the same as get_home_data, but uses a different endpoint and includes non-robotic vacuums."""
530        try:
531            self._home_data_limiter.try_acquire("home_data")
532        except BucketFullException as ex:
533            _LOGGER.info(ex.meta_info)
534            raise RoborockRateLimit("Reached maximum requests for home data. Please try again later.") from ex
535        rriot = user_data.rriot
536        home_id = await self._get_home_id(user_data)
537        if rriot.r.a is None:
538            raise RoborockException("Missing field 'a' in rriot reference")
539        home_request = PreparedRequest(
540            rriot.r.a,
541            self.session,
542            {
543                "Authorization": _get_hawk_authentication(rriot, "/v3/user/homes/" + str(home_id)),
544            },
545        )
546        home_response = await home_request.request("get", "/v3/user/homes/" + str(home_id))
547        if not home_response.get("success"):
548            raise RoborockException(home_response)
549        home_data = home_response.get("result")
550        if isinstance(home_data, dict):
551            return HomeData.from_dict(home_data)
552        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]:
554    async def get_rooms(self, user_data: UserData, home_id: int | None = None) -> list[HomeDataRoom]:
555        rriot = user_data.rriot
556        if rriot is None:
557            raise RoborockException("rriot is none")
558        if home_id is None:
559            home_id = await self._get_home_id(user_data)
560        if rriot.r.a is None:
561            raise RoborockException("Missing field 'a' in rriot reference")
562        room_request = PreparedRequest(
563            rriot.r.a,
564            self.session,
565            {
566                "Authorization": _get_hawk_authentication(rriot, "/v2/user/homes/" + str(home_id)),
567            },
568        )
569        room_response = await room_request.request("get", f"/user/homes/{str(home_id)}/rooms" + str(home_id))
570        if not room_response.get("success"):
571            raise RoborockException(room_response)
572        rooms = room_response.get("result")
573        if isinstance(rooms, list):
574            output_list = []
575            for room in rooms:
576                output_list.append(HomeDataRoom.from_dict(room))
577            return output_list
578        else:
579            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]:
581    async def get_scenes(self, user_data: UserData, device_id: str) -> list[HomeDataScene]:
582        rriot = user_data.rriot
583        if rriot is None:
584            raise RoborockException("rriot is none")
585        if rriot.r.a is None:
586            raise RoborockException("Missing field 'a' in rriot reference")
587        scenes_request = PreparedRequest(
588            rriot.r.a,
589            self.session,
590            {
591                "Authorization": _get_hawk_authentication(rriot, f"/user/scene/device/{str(device_id)}"),
592            },
593        )
594        scenes_response = await scenes_request.request("get", f"/user/scene/device/{str(device_id)}")
595        if not scenes_response.get("success"):
596            raise RoborockException(scenes_response)
597        scenes = scenes_response.get("result")
598        if isinstance(scenes, list):
599            return [HomeDataScene.from_dict(scene) for scene in scenes]
600        else:
601            raise RoborockException("scene_response result was an unexpected type")
async def execute_scene( self, user_data: roborock.data.containers.UserData, scene_id: int) -> None:
603    async def execute_scene(self, user_data: UserData, scene_id: int) -> None:
604        rriot = user_data.rriot
605        if rriot is None:
606            raise RoborockException("rriot is none")
607        if rriot.r.a is None:
608            raise RoborockException("Missing field 'a' in rriot reference")
609        execute_scene_request = PreparedRequest(
610            rriot.r.a,
611            self.session,
612            {
613                "Authorization": _get_hawk_authentication(rriot, f"/user/scene/{str(scene_id)}/execute"),
614            },
615        )
616        execute_scene_response = await execute_scene_request.request("POST", f"/user/scene/{str(scene_id)}/execute")
617        if not execute_scene_response.get("success"):
618            raise RoborockException(execute_scene_response)
async def get_schedules( self, user_data: roborock.data.containers.UserData, device_id: str) -> list[roborock.data.containers.HomeDataSchedule]:
620    async def get_schedules(self, user_data: UserData, device_id: str) -> list[HomeDataSchedule]:
621        rriot = user_data.rriot
622        if rriot is None:
623            raise RoborockException("rriot is none")
624        if rriot.r.a is None:
625            raise RoborockException("Missing field 'a' in rriot reference")
626        schedules_request = PreparedRequest(
627            rriot.r.a,
628            self.session,
629            {
630                "Authorization": _get_hawk_authentication(rriot, f"/user/devices/{device_id}/jobs"),
631            },
632        )
633        schedules_response = await schedules_request.request("get", f"/user/devices/{str(device_id)}/jobs")
634        if not schedules_response.get("success"):
635            raise RoborockException(schedules_response)
636        schedules = schedules_response.get("result")
637        if isinstance(schedules, list):
638            return [HomeDataSchedule.from_dict(schedule) for schedule in schedules]
639        else:
640            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:
642    async def get_products(self, user_data: UserData) -> ProductResponse:
643        """Gets all products and their schemas, good for determining status codes and model numbers."""
644        base_url = await self.base_url
645        header_clientid = self._get_header_client_id()
646        product_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
647        product_response = await product_request.request(
648            "get",
649            "/api/v4/product",
650            headers={"Authorization": user_data.token},
651        )
652        if product_response is None:
653            raise RoborockException("home_id_response is None")
654        if product_response.get("code") != 200:
655            raise RoborockException(f"{product_response.get('msg')} - response code: {product_response.get('code')}")
656        result = product_response.get("data")
657        if isinstance(result, dict):
658            return ProductResponse.from_dict(result)
659        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):
661    async def download_code(self, user_data: UserData, product_id: int):
662        base_url = await self.base_url
663        header_clientid = self._get_header_client_id()
664        product_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
665        request = {"apilevel": 99999, "productids": [product_id], "type": 2}
666        response = await product_request.request(
667            "post",
668            "/api/v1/appplugin",
669            json=request,
670            headers={"Authorization": user_data.token, "Content-Type": "application/json"},
671        )
672        return response["data"][0]["url"]
async def download_category_code(self, user_data: roborock.data.containers.UserData):
674    async def download_category_code(self, user_data: UserData):
675        base_url = await self.base_url
676        header_clientid = self._get_header_client_id()
677        product_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
678        response = await product_request.request(
679            "get",
680            "api/v1/plugins?apiLevel=99999&type=2",
681            headers={
682                "Authorization": user_data.token,
683            },
684        )
685        return {r["category"]: r["url"] for r in response["data"]["categoryPluginList"]}
class PreparedRequest:
688class PreparedRequest:
689    def __init__(
690        self, base_url: str, session: aiohttp.ClientSession | None = None, base_headers: dict | None = None
691    ) -> None:
692        self.base_url = base_url
693        self.base_headers = base_headers or {}
694        self.session = session
695
696    async def request(self, method: str, url: str, params=None, data=None, headers=None, json=None) -> dict:
697        _url = "/".join(s.strip("/") for s in [self.base_url, url])
698        _headers = {**self.base_headers, **(headers or {})}
699        close_session = self.session is None
700        session = self.session if self.session is not None else aiohttp.ClientSession()
701        try:
702            async with session.request(method, _url, params=params, data=data, headers=_headers, json=json) as resp:
703                return await resp.json()
704        except ContentTypeError as err:
705            """If we get an error, lets log everything for debugging."""
706            try:
707                resp_json = await resp.json(content_type=None)
708                _LOGGER.info("Resp: %s", resp_json)
709            except ContentTypeError as err_2:
710                _LOGGER.info(err_2)
711            resp_raw = await resp.read()
712            _LOGGER.info("Resp raw: %s", resp_raw)
713            # Still raise the err so that it's clear it failed.
714            raise err
715        finally:
716            if close_session:
717                await session.close()
PreparedRequest( base_url: str, session: aiohttp.client.ClientSession | None = None, base_headers: dict | None = None)
689    def __init__(
690        self, base_url: str, session: aiohttp.ClientSession | None = None, base_headers: dict | None = None
691    ) -> None:
692        self.base_url = base_url
693        self.base_headers = base_headers or {}
694        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:
696    async def request(self, method: str, url: str, params=None, data=None, headers=None, json=None) -> dict:
697        _url = "/".join(s.strip("/") for s in [self.base_url, url])
698        _headers = {**self.base_headers, **(headers or {})}
699        close_session = self.session is None
700        session = self.session if self.session is not None else aiohttp.ClientSession()
701        try:
702            async with session.request(method, _url, params=params, data=data, headers=_headers, json=json) as resp:
703                return await resp.json()
704        except ContentTypeError as err:
705            """If we get an error, lets log everything for debugging."""
706            try:
707                resp_json = await resp.json(content_type=None)
708                _LOGGER.info("Resp: %s", resp_json)
709            except ContentTypeError as err_2:
710                _LOGGER.info(err_2)
711            resp_raw = await resp.read()
712            _LOGGER.info("Resp raw: %s", resp_raw)
713            # Still raise the err so that it's clear it failed.
714            raise err
715        finally:
716            if close_session:
717                await session.close()
class UserWebApiClient:
753class UserWebApiClient:
754    """Wrapper around RoborockApiClient to provide information for a specific user.
755
756    This binds a RoborockApiClient to a specific user context with the
757    provided UserData. This allows for easier access to user-specific data,
758    to avoid needing to pass UserData around and mock out the web API.
759    """
760
761    def __init__(self, web_api: RoborockApiClient, user_data: UserData) -> None:
762        """Initialize the wrapper with the API client and user data."""
763        self._web_api = web_api
764        self._user_data = user_data
765
766    async def get_home_data(self) -> HomeData:
767        """Fetch home data using the API client."""
768        return await self._web_api.get_home_data_v3(self._user_data)
769
770    async def get_routines(self, device_id: str) -> list[HomeDataScene]:
771        """Fetch routines (scenes) for a specific device."""
772        return await self._web_api.get_scenes(self._user_data, device_id)
773
774    async def execute_routine(self, scene_id: int) -> None:
775        """Execute a specific routine (scene) by its ID."""
776        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)
761    def __init__(self, web_api: RoborockApiClient, user_data: UserData) -> None:
762        """Initialize the wrapper with the API client and user data."""
763        self._web_api = web_api
764        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:
766    async def get_home_data(self) -> HomeData:
767        """Fetch home data using the API client."""
768        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]:
770    async def get_routines(self, device_id: str) -> list[HomeDataScene]:
771        """Fetch routines (scenes) for a specific device."""
772        return await self._web_api.get_scenes(self._user_data, device_id)

Fetch routines (scenes) for a specific device.

async def execute_routine(self, scene_id: int) -> None:
774    async def execute_routine(self, scene_id: int) -> None:
775        """Execute a specific routine (scene) by its ID."""
776        await self._web_api.execute_scene(self._user_data, scene_id)

Execute a specific routine (scene) by its ID.