roborock.web_api

  1import base64
  2import hashlib
  3import hmac
  4import json
  5import logging
  6import math
  7import secrets
  8import string
  9import time
 10from collections.abc import Callable
 11from dataclasses import dataclass
 12
 13import aiohttp
 14from aiohttp import ContentTypeError, FormData
 15from pyrate_limiter import Duration, Limiter, Rate
 16
 17from roborock import HomeDataSchedule
 18from roborock.data import FirmwareInfo, 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# Fallback user agreement version, used only if the latest version cannot be fetched.
 42DEFAULT_AGREEMENT_MAJOR_VERSION = 14
 43DEFAULT_AGREEMENT_MINOR_VERSION = 0
 44
 45
 46@dataclass
 47class IotLoginInfo:
 48    """Information about the login to the iot server."""
 49
 50    base_url: str
 51    country_code: str
 52    country: str
 53
 54
 55class RoborockApiClient:
 56    _LOGIN_RATES = [
 57        Rate(1, Duration.SECOND),
 58        Rate(3, Duration.MINUTE),
 59        Rate(10, Duration.HOUR),
 60        Rate(20, Duration.DAY),
 61    ]
 62    _HOME_DATA_RATES = [
 63        Rate(1, Duration.SECOND),
 64        Rate(3, Duration.MINUTE),
 65        Rate(5, Duration.HOUR),
 66        Rate(40, Duration.DAY),
 67    ]
 68
 69    _login_limiter = Limiter(_LOGIN_RATES)
 70    _home_data_limiter = Limiter(_HOME_DATA_RATES)
 71
 72    def __init__(
 73        self, username: str, base_url: str | None = None, session: aiohttp.ClientSession | None = None
 74    ) -> None:
 75        """Sample API Client."""
 76        self._username = username
 77        self._base_url = base_url
 78        self._device_identifier = secrets.token_urlsafe(16)
 79        self.session = session
 80        self._iot_login_info: IotLoginInfo | None = None
 81        self._base_urls = BASE_URLS if base_url is None else [base_url]
 82
 83    async def _get_iot_login_info(self) -> IotLoginInfo:
 84        if self._iot_login_info is None:
 85            for iot_url in self._base_urls:
 86                url_request = PreparedRequest(iot_url, self.session)
 87                response = await url_request.request(
 88                    "post",
 89                    "/api/v1/getUrlByEmail",
 90                    params={"email": self._username, "needtwostepauth": "false"},
 91                )
 92                if response is None:
 93                    continue
 94                response_code = response.get("code")
 95                if response_code != 200:
 96                    if response_code == 2003:
 97                        raise RoborockInvalidEmail("Your email was incorrectly formatted.")
 98                    elif response_code == 1001:
 99                        raise RoborockMissingParameters(
100                            "You are missing parameters for this request, are you sure you entered your username?"
101                        )
102                    else:
103                        raise RoborockException(f"{response.get('msg')} - response code: {response_code}")
104                country_code = response["data"]["countrycode"]
105                country = response["data"]["country"]
106                if country_code is not None or country is not None:
107                    self._iot_login_info = IotLoginInfo(
108                        base_url=response["data"]["url"],
109                        country=country,
110                        country_code=country_code,
111                    )
112                    _LOGGER.debug("Country determined to be %s and code is %s", country, country_code)
113                    return self._iot_login_info
114            raise RoborockNoResponseFromBaseURL(
115                "No account was found for any base url we tried. Either your email is incorrect or we do not have a"
116                " record of the roborock server your device is on."
117            )
118        return self._iot_login_info
119
120    @property
121    async def base_url(self):
122        if self._base_url is not None:
123            return self._base_url
124        return (await self._get_iot_login_info()).base_url
125
126    @property
127    async def country(self):
128        return (await self._get_iot_login_info()).country
129
130    @property
131    async def country_code(self):
132        return (await self._get_iot_login_info()).country_code
133
134    def _get_header_client_id(self):
135        md5 = hashlib.md5()
136        md5.update(self._username.encode())
137        md5.update(self._device_identifier.encode())
138        return base64.b64encode(md5.digest()).decode()
139
140    async def nc_prepare(self, user_data: UserData, timezone: str) -> dict:
141        """This gets a few critical parameters for adding a device to your account."""
142        if (
143            user_data.rriot is None
144            or user_data.rriot.r is None
145            or user_data.rriot.u is None
146            or user_data.rriot.r.a is None
147        ):
148            raise RoborockException("Your userdata is missing critical attributes.")
149        base_url = user_data.rriot.r.a
150        prepare_request = PreparedRequest(base_url, self.session)
151        hid = await self._get_home_id(user_data)
152
153        data = FormData()
154        data.add_field("hid", hid)
155        data.add_field("tzid", timezone)
156
157        prepare_response = await prepare_request.request(
158            "post",
159            "/nc/prepare",
160            headers={
161                "Authorization": _get_hawk_authentication(
162                    user_data.rriot, "/nc/prepare", {"hid": hid, "tzid": timezone}
163                ),
164            },
165            data=data,
166        )
167
168        if prepare_response is None:
169            raise RoborockException("prepare_response is None")
170        if not prepare_response.get("success"):
171            raise RoborockException(f"{prepare_response.get('msg')} - response code: {prepare_response.get('code')}")
172
173        return prepare_response["result"]
174
175    async def add_device(self, user_data: UserData, s: str, t: str) -> dict:
176        """This will add a new device to your account
177        it is recommended to only use this during a pairing cycle with a device.
178        Please see here: https://github.com/Python-roborock/Roborockmitmproxy/blob/main/handshake_protocol.md
179        """
180        if (
181            user_data.rriot is None
182            or user_data.rriot.r is None
183            or user_data.rriot.u is None
184            or user_data.rriot.r.a is None
185        ):
186            raise RoborockException("Your userdata is missing critical attributes.")
187        base_url = user_data.rriot.r.a
188        add_device_request = PreparedRequest(base_url, self.session)
189
190        add_device_response = await add_device_request.request(
191            "GET",
192            "/user/devices/newadd",
193            headers={
194                "Authorization": _get_hawk_authentication(
195                    user_data.rriot, "/user/devices/newadd", params={"s": s, "t": t}
196                ),
197            },
198            params={"s": s, "t": t},
199        )
200
201        if add_device_response is None:
202            raise RoborockException("add_device is None")
203        if not add_device_response.get("success"):
204            raise RoborockException(
205                f"{add_device_response.get('msg')} - response code: {add_device_response.get('code')}"
206            )
207
208        return add_device_response["result"]
209
210    async def request_code(self) -> None:
211        if not await self._login_limiter.try_acquire_async("login", blocking=True, timeout=1):
212            raise RoborockRateLimit("Reached maximum requests for login. Please try again later.")
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        if not await self._login_limiter.try_acquire_async("login", blocking=True, timeout=1):
243            raise RoborockRateLimit("Reached maximum requests for login. Please try again later.")
244        base_url = await self.base_url
245        header_clientid = self._get_header_client_id()
246        code_request = PreparedRequest(
247            base_url,
248            self.session,
249            {
250                "header_clientid": header_clientid,
251                "Content-Type": "application/x-www-form-urlencoded",
252                "header_clientlang": "en",
253            },
254        )
255
256        code_response = await code_request.request(
257            "post",
258            "/api/v4/email/code/send",
259            data={"email": self._username, "type": "login", "platform": ""},
260        )
261        if code_response is None:
262            raise RoborockException("Failed to get a response from send email code")
263        response_code = code_response.get("code")
264        if response_code != 200:
265            _LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response)
266            if response_code == 2008:
267                raise RoborockAccountDoesNotExist("Account does not exist - check your login and try again.")
268            elif response_code == 9002:
269                raise RoborockTooFrequentCodeRequests("You have attempted to request too many codes. Try again later")
270            elif response_code == 3030 and len(self._base_urls) > 1:
271                self._base_urls = self._base_urls[1:]
272                self._iot_login_info = None
273                return await self.request_code_v4()
274            else:
275                raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}")
276
277    async def _sign_key_v3(self, s: str) -> str:
278        """Sign a randomly generated string."""
279        base_url = await self.base_url
280        header_clientid = self._get_header_client_id()
281        code_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
282
283        code_response = await code_request.request(
284            "post",
285            "/api/v3/key/sign",
286            params={"s": s},
287        )
288
289        if not code_response or "data" not in code_response or "k" not in code_response["data"]:
290            raise RoborockException("Failed to get a response from sign key")
291        response_code = code_response.get("code")
292
293        if response_code != 200:
294            _LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response)
295            raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}")
296
297        return code_response["data"]["k"]
298
299    async def _get_agreement_version(self, country: str) -> dict[str, int]:
300        """Get the latest user agreement version for the given country.
301
302        The login endpoint rejects a stale agreement version with code 3006. The
303        current version differs per server and per country, so it must be looked
304        up rather than hardcoded. Falls back to the previously hardcoded values
305        if the lookup fails so that login is never blocked by this request.
306        """
307        try:
308            base_url = await self.base_url
309            agreement_request = PreparedRequest(base_url, self.session, {"header_clientlang": "en"})
310            response = await agreement_request.request(
311                "get",
312                "/api/v3/app/agreement/latest",
313                params={"country": country},
314            )
315            if response is not None and response.get("code") == 200:
316                data = response.get("data") or {}
317                major = data.get("majorVersion")
318                minor = data.get("minorVersion")
319                if isinstance(major, int) and isinstance(minor, int):
320                    _LOGGER.debug("Using user agreement version %s.%s for %s", major, minor, country)
321                    return {"majorVersion": major, "minorVersion": minor}
322            _LOGGER.debug("Unexpected agreement version response: %s", response)
323        except RoborockException as err:
324            _LOGGER.debug("Could not fetch latest user agreement version: %s", err)
325        return {"majorVersion": DEFAULT_AGREEMENT_MAJOR_VERSION, "minorVersion": DEFAULT_AGREEMENT_MINOR_VERSION}
326
327    async def code_login_v4(
328        self, code: int | str, country: str | None = None, country_code: int | None = None
329    ) -> UserData:
330        """
331        Login via code authentication.
332        :param code: The code from the email.
333        :param country: The two-character representation of the country, i.e. "US"
334        :param country_code: the country phone number code i.e. 1 for US.
335        """
336        base_url = await self.base_url
337        if country is None:
338            country = await self.country
339        if country_code is None:
340            country_code = await self.country_code
341        if country_code is None or country is None:
342            _LOGGER.info("No country code or country found, trying old version of code login.")
343            return await self.code_login(code)
344        header_clientid = self._get_header_client_id()
345        x_mercy_ks = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
346        x_mercy_k = await self._sign_key_v3(x_mercy_ks)
347        login_request = PreparedRequest(
348            base_url,
349            self.session,
350            {
351                "header_clientid": header_clientid,
352                "x-mercy-ks": x_mercy_ks,
353                "x-mercy-k": x_mercy_k,
354                "Content-Type": "application/x-www-form-urlencoded",
355                "header_clientlang": "en",
356                "header_appversion": "4.54.02",
357                "header_phonesystem": "iOS",
358                "header_phonemodel": "iPhone16,1",
359            },
360        )
361        login_response = await login_request.request(
362            "post",
363            "/api/v4/auth/email/login/code",
364            data={
365                "country": country,
366                "countryCode": country_code,
367                "email": self._username,
368                "code": code,
369                **await self._get_agreement_version(country),
370            },
371        )
372        if login_response is None:
373            raise RoborockException("Login request response is None")
374        response_code = login_response.get("code")
375        if response_code != 200:
376            _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response)
377            if response_code == 2018:
378                raise RoborockInvalidCode("Invalid code - check your code and try again.")
379            if response_code == 3009:
380                raise RoborockNoUserAgreement("You must accept the user agreement in the Roborock app to continue.")
381            if response_code == 3006:
382                raise RoborockInvalidUserAgreement(
383                    "User agreement must be accepted again - or you are attempting to use the Mi Home app account."
384                )
385            if response_code == 3039:
386                raise RoborockAccountDoesNotExist(
387                    "This account does not exist - please ensure that you selected the right region and email."
388                )
389            raise RoborockException(f"{login_response.get('msg')} - response code: {response_code}")
390        user_data = login_response.get("data")
391        if not isinstance(user_data, dict):
392            raise RoborockException("Got unexpected data type for user_data")
393        return UserData.from_dict(user_data)
394
395    async def pass_login(self, password: str) -> UserData:
396        if not await self._login_limiter.try_acquire_async("login", blocking=True, timeout=1):
397            raise RoborockRateLimit("Reached maximum requests for login. Please try again later.")
398        base_url = await self.base_url
399        header_clientid = self._get_header_client_id()
400
401        login_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
402        login_response = await login_request.request(
403            "post",
404            "/api/v1/login",
405            params={
406                "username": self._username,
407                "password": password,
408                "needtwostepauth": "false",
409            },
410        )
411        if login_response is None:
412            raise RoborockException("Login response is none")
413        if login_response.get("code") != 200:
414            _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response)
415            raise RoborockException(f"{login_response.get('msg')} - response code: {login_response.get('code')}")
416        user_data = login_response.get("data")
417        if not isinstance(user_data, dict):
418            raise RoborockException("Got unexpected data type for user_data")
419        return UserData.from_dict(user_data)
420
421    async def pass_login_v3(self, password: str) -> UserData:
422        """Seemingly it follows the format below, but password is encrypted in some manner.
423        # login_response = await login_request.request(
424        #     "post",
425        #     "/api/v3/auth/email/login",
426        #     params={
427        #         "email": self._username,
428        #         "password": password,
429        #         "twoStep": 1,
430        #         "version": 0
431        #     },
432        # )
433        """
434        raise NotImplementedError("Pass_login_v3 has not yet been implemented")
435
436    async def code_login(self, code: int | str) -> UserData:
437        base_url = await self.base_url
438        header_clientid = self._get_header_client_id()
439
440        login_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
441        login_response = await login_request.request(
442            "post",
443            "/api/v1/loginWithCode",
444            params={
445                "username": self._username,
446                "verifycode": code,
447                "verifycodetype": "AUTH_EMAIL_CODE",
448            },
449        )
450        if login_response is None:
451            raise RoborockException("Login request response is None")
452        response_code = login_response.get("code")
453        if response_code != 200:
454            _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response)
455            if response_code == 2018:
456                raise RoborockInvalidCode("Invalid code - check your code and try again.")
457            if response_code == 3009:
458                raise RoborockNoUserAgreement("You must accept the user agreement in the Roborock app to continue.")
459            if response_code == 3006:
460                raise RoborockInvalidUserAgreement(
461                    "User agreement must be accepted again - or you are attempting to use the Mi Home app account."
462                )
463            raise RoborockException(f"{login_response.get('msg')} - response code: {response_code}")
464        user_data = login_response.get("data")
465        if not isinstance(user_data, dict):
466            raise RoborockException("Got unexpected data type for user_data")
467        return UserData.from_dict(user_data)
468
469    async def _get_home_id(self, user_data: UserData):
470        base_url = await self.base_url
471        header_clientid = self._get_header_client_id()
472        home_id_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
473        home_id_response = await home_id_request.request(
474            "get",
475            "/api/v1/getHomeDetail",
476            headers={"Authorization": user_data.token},
477        )
478        if home_id_response is None:
479            raise RoborockException("home_id_response is None")
480        if home_id_response.get("code") != 200:
481            _LOGGER.info("Get Home Id failed with the following context: %s", home_id_response)
482            if home_id_response.get("code") == 2010:
483                raise RoborockInvalidCredentials(
484                    f"Invalid credentials ({home_id_response.get('msg')}) - check your login and try again."
485                )
486            raise RoborockException(f"{home_id_response.get('msg')} - response code: {home_id_response.get('code')}")
487
488        return home_id_response["data"]["rrHomeId"]
489
490    async def get_home_data(self, user_data: UserData) -> HomeData:
491        if not self._home_data_limiter.try_acquire("home_data", blocking=False):
492            raise RoborockRateLimit("Reached maximum requests for home data. Please try again later.")
493        rriot = user_data.rriot
494        if rriot is None:
495            raise RoborockException("rriot is none")
496        home_id = await self._get_home_id(user_data)
497        if rriot.r.a is None:
498            raise RoborockException("Missing field 'a' in rriot reference")
499        home_request = PreparedRequest(
500            rriot.r.a,
501            self.session,
502            {
503                "Authorization": _get_hawk_authentication(rriot, f"/user/homes/{str(home_id)}"),
504            },
505        )
506        home_response = await home_request.request("get", "/user/homes/" + str(home_id))
507        if not home_response.get("success"):
508            raise RoborockException(home_response)
509        home_data = home_response.get("result")
510        if isinstance(home_data, dict):
511            return HomeData.from_dict(home_data)
512        else:
513            raise RoborockException("home_response result was an unexpected type")
514
515    async def get_home_data_v2(self, user_data: UserData) -> HomeData:
516        """This is the same as get_home_data, but uses a different endpoint and includes non-robotic vacuums."""
517        if not self._home_data_limiter.try_acquire("home_data", blocking=False):
518            raise RoborockRateLimit("Reached maximum requests for home data. Please try again later.")
519        rriot = user_data.rriot
520        if rriot is None:
521            raise RoborockException("rriot is none")
522        home_id = await self._get_home_id(user_data)
523        if rriot.r.a is None:
524            raise RoborockException("Missing field 'a' in rriot reference")
525        home_request = PreparedRequest(
526            rriot.r.a,
527            self.session,
528            {
529                "Authorization": _get_hawk_authentication(rriot, "/v2/user/homes/" + str(home_id)),
530            },
531        )
532        home_response = await home_request.request("get", "/v2/user/homes/" + str(home_id))
533        if not home_response.get("success"):
534            raise RoborockException(home_response)
535        home_data = home_response.get("result")
536        if isinstance(home_data, dict):
537            return HomeData.from_dict(home_data)
538        else:
539            raise RoborockException("home_response result was an unexpected type")
540
541    async def get_home_data_v3(self, user_data: UserData) -> HomeData:
542        """This is the same as get_home_data, but uses a different endpoint and includes non-robotic vacuums."""
543        if not self._home_data_limiter.try_acquire("home_data", blocking=False):
544            raise RoborockRateLimit("Reached maximum requests for home data. Please try again later.")
545        rriot = user_data.rriot
546        home_id = await self._get_home_id(user_data)
547        if rriot.r.a is None:
548            raise RoborockException("Missing field 'a' in rriot reference")
549        home_request = PreparedRequest(
550            rriot.r.a,
551            self.session,
552            {
553                "Authorization": _get_hawk_authentication(rriot, "/v3/user/homes/" + str(home_id)),
554            },
555        )
556        home_response = await home_request.request("get", "/v3/user/homes/" + str(home_id))
557        if not home_response.get("success"):
558            raise RoborockException(home_response)
559        home_data = home_response.get("result")
560        if isinstance(home_data, dict):
561            return HomeData.from_dict(home_data)
562        raise RoborockException(f"home_response result was an unexpected type: {home_data}")
563
564    async def get_rooms(self, user_data: UserData, home_id: int | None = None) -> list[HomeDataRoom]:
565        rriot = user_data.rriot
566        if rriot is None:
567            raise RoborockException("rriot is none")
568        if home_id is None:
569            home_id = await self._get_home_id(user_data)
570        if rriot.r.a is None:
571            raise RoborockException("Missing field 'a' in rriot reference")
572        room_request = PreparedRequest(
573            rriot.r.a,
574            self.session,
575            {
576                "Authorization": _get_hawk_authentication(rriot, f"/user/homes/{home_id}/rooms"),
577            },
578        )
579        room_response = await room_request.request("get", f"/user/homes/{home_id}/rooms")
580        if not room_response.get("success"):
581            raise RoborockException(room_response)
582        rooms = room_response.get("result")
583        if isinstance(rooms, list):
584            output_list = []
585            for room in rooms:
586                output_list.append(HomeDataRoom.from_dict(room))
587            return output_list
588        else:
589            raise RoborockException("home_response result was an unexpected type")
590
591    async def get_shared_device_rooms(self, user_data: UserData, device_id: str) -> list[HomeDataRoom]:
592        """Fetch room names for a shared (received) device."""
593        rriot = user_data.rriot
594        if rriot is None:
595            raise RoborockException("rriot is none")
596        if rriot.r.a is None:
597            raise RoborockException("Missing field 'a' in rriot reference")
598        path = f"/user/deviceshare/query/{device_id}/rooms"
599        room_request = PreparedRequest(
600            rriot.r.a,
601            self.session,
602            {"Authorization": _get_hawk_authentication(rriot, path)},
603        )
604        room_response = await room_request.request("get", path)
605        if not room_response.get("success"):
606            raise RoborockException(room_response)
607        rooms = room_response.get("result")
608        if isinstance(rooms, list):
609            output_list = []
610            for room in rooms:
611                normalized_room = room
612                if isinstance(room, dict) and "id" not in room and "roomId" in room:
613                    normalized_room = {**room, "id": room["roomId"]}
614                output_list.append(HomeDataRoom.from_dict(normalized_room))
615            return output_list
616        raise RoborockException("get_shared_device_rooms result was an unexpected type")
617
618    async def get_scenes(self, user_data: UserData, device_id: str) -> list[HomeDataScene]:
619        rriot = user_data.rriot
620        if rriot is None:
621            raise RoborockException("rriot is none")
622        if rriot.r.a is None:
623            raise RoborockException("Missing field 'a' in rriot reference")
624        scenes_request = PreparedRequest(
625            rriot.r.a,
626            self.session,
627            {
628                "Authorization": _get_hawk_authentication(rriot, f"/user/scene/device/{str(device_id)}"),
629            },
630        )
631        scenes_response = await scenes_request.request("get", f"/user/scene/device/{str(device_id)}")
632        if not scenes_response.get("success"):
633            raise RoborockException(scenes_response)
634        scenes = scenes_response.get("result")
635        if isinstance(scenes, list):
636            return [HomeDataScene.from_dict(scene) for scene in scenes]
637        else:
638            raise RoborockException("scene_response result was an unexpected type")
639
640    async def get_firmware_info(self, user_data: UserData, device_id: str) -> FirmwareInfo:
641        """Get firmware/OTA info for a device (latest version + updatable flag)."""
642        rriot = user_data.rriot
643        if rriot is None:
644            raise RoborockException("rriot is none")
645        if rriot.r.a is None:
646            raise RoborockException("Missing field 'a' in rriot reference")
647        path = f"/ota/firmware/{device_id}/updatev2"
648        params = {"lang": "en"}
649        firmware_request = PreparedRequest(
650            rriot.r.a,
651            self.session,
652            {
653                "Authorization": _get_hawk_authentication(rriot, path, params=params),
654            },
655        )
656        firmware_response = await firmware_request.request("get", path, params=params)
657        if not firmware_response or not firmware_response.get("success"):
658            raise RoborockException(firmware_response)
659        return FirmwareInfo.from_dict(firmware_response.get("result") or {})
660
661    async def start_firmware_update(self, user_data: UserData, device_id: str) -> None:
662        """Trigger the (irreversible) firmware update for a device.
663
664        The device downloads and flashes the latest firmware reported by
665        :meth:`get_firmware_info`.
666        """
667        rriot = user_data.rriot
668        if rriot is None:
669            raise RoborockException("rriot is none")
670        if rriot.r.a is None:
671            raise RoborockException("Missing field 'a' in rriot reference")
672        path = f"/ota/device/{device_id}/upgrade"
673        upgrade_request = PreparedRequest(
674            rriot.r.a,
675            self.session,
676            {
677                "Authorization": _get_hawk_authentication(rriot, path),
678            },
679        )
680        upgrade_response = await upgrade_request.request("post", path)
681        if not upgrade_response or not upgrade_response.get("success"):
682            raise RoborockException(upgrade_response)
683
684    async def set_silent_ota(self, user_data: UserData, device_id: str, enabled: bool) -> None:
685        """Enable/disable automatic (silent) firmware updates for a device.
686
687        The current value is reported as ``silent_ota_switch`` on the home-data device.
688        """
689        rriot = user_data.rriot
690        if rriot is None:
691            raise RoborockException("rriot is none")
692        if rriot.r.a is None:
693            raise RoborockException("Missing field 'a' in rriot reference")
694        path = f"/user/devices/{device_id}"
695        formdata = {"silentOtaSwitch": "true" if enabled else "false"}
696        silent_ota_request = PreparedRequest(
697            rriot.r.a,
698            self.session,
699            {
700                "Authorization": _get_hawk_authentication(rriot, path, formdata=formdata),
701            },
702        )
703        silent_ota_response = await silent_ota_request.request("put", path, data=formdata)
704        if not silent_ota_response or not silent_ota_response.get("success"):
705            raise RoborockException(silent_ota_response)
706
707    async def execute_scene(self, user_data: UserData, scene_id: int) -> None:
708        rriot = user_data.rriot
709        if rriot is None:
710            raise RoborockException("rriot is none")
711        if rriot.r.a is None:
712            raise RoborockException("Missing field 'a' in rriot reference")
713        execute_scene_request = PreparedRequest(
714            rriot.r.a,
715            self.session,
716            {
717                "Authorization": _get_hawk_authentication(rriot, f"/user/scene/{str(scene_id)}/execute"),
718            },
719        )
720        execute_scene_response = await execute_scene_request.request("POST", f"/user/scene/{str(scene_id)}/execute")
721        if not execute_scene_response.get("success"):
722            raise RoborockException(execute_scene_response)
723
724    async def get_schedules(self, user_data: UserData, device_id: str) -> list[HomeDataSchedule]:
725        rriot = user_data.rriot
726        if rriot is None:
727            raise RoborockException("rriot is none")
728        if rriot.r.a is None:
729            raise RoborockException("Missing field 'a' in rriot reference")
730        schedules_request = PreparedRequest(
731            rriot.r.a,
732            self.session,
733            {
734                "Authorization": _get_hawk_authentication(rriot, f"/user/devices/{device_id}/jobs"),
735            },
736        )
737        schedules_response = await schedules_request.request("get", f"/user/devices/{str(device_id)}/jobs")
738        if not schedules_response.get("success"):
739            raise RoborockException(schedules_response)
740        schedules = schedules_response.get("result")
741        if isinstance(schedules, list):
742            return [HomeDataSchedule.from_dict(schedule) for schedule in schedules]
743        else:
744            raise RoborockException(f"schedule_response result was an unexpected type: {schedules}")
745
746    async def create_job(self, user_data: UserData, device_id: str, job: dict) -> dict:
747        """Create a /jobs entry (schedule or one-time room clean) on a B01 device.
748
749        Body-bearing writes must sign the request body in the Hawk payload slot and send those same
750        compact bytes via ``data=``; ``json=`` would re-serialize with spaces and break the MAC.
751        """
752        rriot = user_data.rriot
753        if rriot is None:
754            raise RoborockException("rriot is none")
755        if rriot.r.a is None:
756            raise RoborockException("Missing field 'a' in rriot reference")
757        path = f"/user/devices/{device_id}/jobs"
758        job_request = PreparedRequest(
759            rriot.r.a,
760            self.session,
761            {
762                "Authorization": _get_hawk_authentication(rriot, path, body=job),
763                "Content-Type": "application/json",
764            },
765        )
766        response = await job_request.request("post", path, data=_compact_json(job).encode())
767        if not response.get("success"):
768            raise RoborockException(response)
769        return response
770
771    async def get_products(self, user_data: UserData) -> ProductResponse:
772        """Gets all products and their schemas, good for determining status codes and model numbers."""
773        base_url = await self.base_url
774        header_clientid = self._get_header_client_id()
775        product_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
776        product_response = await product_request.request(
777            "get",
778            "/api/v4/product",
779            headers={"Authorization": user_data.token},
780        )
781        if product_response is None:
782            raise RoborockException("home_id_response is None")
783        if product_response.get("code") != 200:
784            raise RoborockException(f"{product_response.get('msg')} - response code: {product_response.get('code')}")
785        result = product_response.get("data")
786        if isinstance(result, dict):
787            return ProductResponse.from_dict(result)
788        raise RoborockException("product result was an unexpected type")
789
790    async def download_code(self, user_data: UserData, product_id: int):
791        base_url = await self.base_url
792        header_clientid = self._get_header_client_id()
793        product_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
794        request = {"apilevel": 99999, "productids": [product_id], "type": 2}
795        response = await product_request.request(
796            "post",
797            "/api/v1/appplugin",
798            json=request,
799            headers={"Authorization": user_data.token, "Content-Type": "application/json"},
800        )
801        return response["data"][0]["url"]
802
803    async def download_category_code(self, user_data: UserData):
804        base_url = await self.base_url
805        header_clientid = self._get_header_client_id()
806        product_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
807        response = await product_request.request(
808            "get",
809            "api/v1/plugins?apiLevel=99999&type=2",
810            headers={
811                "Authorization": user_data.token,
812            },
813        )
814        return {r["category"]: r["url"] for r in response["data"]["categoryPluginList"]}
815
816
817class PreparedRequest:
818    def __init__(
819        self, base_url: str, session: aiohttp.ClientSession | None = None, base_headers: dict | None = None
820    ) -> None:
821        self.base_url = base_url
822        self.base_headers = base_headers or {}
823        self.session = session
824
825    async def request(self, method: str, url: str, params=None, data=None, headers=None, json=None) -> dict:
826        _url = "/".join(s.strip("/") for s in [self.base_url, url])
827        _headers = {**self.base_headers, **(headers or {})}
828        close_session = self.session is None
829        session = self.session if self.session is not None else aiohttp.ClientSession()
830        try:
831            async with session.request(method, _url, params=params, data=data, headers=_headers, json=json) as resp:
832                return await resp.json()
833        except ContentTypeError as err:
834            """If we get an error, lets log everything for debugging."""
835            try:
836                resp_json = await resp.json(content_type=None)
837                _LOGGER.info("Resp: %s", resp_json)
838            except ContentTypeError as err_2:
839                _LOGGER.info(err_2)
840            resp_raw = await resp.read()
841            _LOGGER.info("Resp raw: %s", resp_raw)
842            # Still raise the err so that it's clear it failed.
843            raise err
844        except (aiohttp.ClientError, TimeoutError, OSError) as err:
845            raise RoborockException(f"Network error contacting {_url}: {err}") from err
846        finally:
847            if close_session:
848                await session.close()
849
850
851def _process_extra_hawk_values(values: dict | None) -> str:
852    if values is None:
853        return ""
854    else:
855        sorted_keys = sorted(values.keys())
856        result = []
857        for key in sorted_keys:
858            value = values.get(key)
859            result.append(f"{key}={value}")
860        return hashlib.md5("&".join(result).encode()).hexdigest()
861
862
863def _compact_json(body: dict) -> str:
864    """Serialize a JSON body to the exact compact bytes that are both signed and sent."""
865    return json.dumps(body, separators=(",", ":"))
866
867
868def _get_hawk_authentication(
869    rriot: RRiot,
870    url: str,
871    formdata: dict | None = None,
872    params: dict | None = None,
873    body: dict | None = None,
874) -> str:
875    timestamp = math.floor(time.time())
876    nonce = secrets.token_urlsafe(6)
877    params_str = _process_extra_hawk_values(params)
878    if body is not None:
879        payload_str = hashlib.md5(_compact_json(body).encode()).hexdigest()
880    else:
881        payload_str = _process_extra_hawk_values(formdata)
882
883    prestr = ":".join(
884        [
885            rriot.u,
886            rriot.s,
887            nonce,
888            str(timestamp),
889            hashlib.md5(url.encode()).hexdigest(),
890            params_str,
891            payload_str,
892        ]
893    )
894    mac = base64.b64encode(hmac.new(rriot.h.encode(), prestr.encode(), hashlib.sha256).digest()).decode()
895    return f'Hawk id="{rriot.u}",s="{rriot.s}",ts="{timestamp}",nonce="{nonce}",mac="{mac}"'
896
897
898class UserWebApiClient:
899    """Wrapper around RoborockApiClient to provide information for a specific user.
900
901    This binds a RoborockApiClient to a specific user context with the
902    provided UserData. This allows for easier access to user-specific data,
903    to avoid needing to pass UserData around and mock out the web API.
904    """
905
906    def __init__(
907        self, web_api: RoborockApiClient, user_data: UserData, unauthorized_hook: Callable[[], None] | None = None
908    ) -> None:
909        """Initialize the wrapper with the API client and user data."""
910        self._web_api = web_api
911        self._user_data = user_data
912        self._unauthorized_hook = unauthorized_hook
913
914    async def get_home_data(self) -> HomeData:
915        """Fetch home data using the API client."""
916        try:
917            return await self._web_api.get_home_data_v3(self._user_data)
918        except RoborockInvalidCredentials:
919            if self._unauthorized_hook:
920                self._unauthorized_hook()
921            raise
922
923    async def get_routines(self, device_id: str) -> list[HomeDataScene]:
924        """Fetch routines (scenes) for a specific device."""
925        try:
926            return await self._web_api.get_scenes(self._user_data, device_id)
927        except RoborockInvalidCredentials:
928            if self._unauthorized_hook:
929                self._unauthorized_hook()
930            raise
931
932    async def get_rooms(self) -> list[HomeDataRoom]:
933        """Fetch rooms using the API client."""
934        try:
935            return await self._web_api.get_rooms(self._user_data)
936        except RoborockInvalidCredentials:
937            if self._unauthorized_hook:
938                self._unauthorized_hook()
939            raise
940
941    async def get_shared_device_rooms(self, device_id: str) -> list[HomeDataRoom]:
942        """Fetch shared-device rooms using the API client."""
943        return await self._web_api.get_shared_device_rooms(self._user_data, device_id)
944
945    async def execute_routine(self, scene_id: int) -> None:
946        """Execute a specific routine (scene) by its ID."""
947        try:
948            await self._web_api.execute_scene(self._user_data, scene_id)
949        except RoborockInvalidCredentials:
950            if self._unauthorized_hook:
951                self._unauthorized_hook()
952            raise
BASE_URLS = ['https://usiot.roborock.com', 'https://euiot.roborock.com', 'https://cniot.roborock.com', 'https://ruiot.roborock.com']
DEFAULT_AGREEMENT_MAJOR_VERSION = 14
DEFAULT_AGREEMENT_MINOR_VERSION = 0
@dataclass
class IotLoginInfo:
47@dataclass
48class IotLoginInfo:
49    """Information about the login to the iot server."""
50
51    base_url: str
52    country_code: str
53    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:
 56class RoborockApiClient:
 57    _LOGIN_RATES = [
 58        Rate(1, Duration.SECOND),
 59        Rate(3, Duration.MINUTE),
 60        Rate(10, Duration.HOUR),
 61        Rate(20, Duration.DAY),
 62    ]
 63    _HOME_DATA_RATES = [
 64        Rate(1, Duration.SECOND),
 65        Rate(3, Duration.MINUTE),
 66        Rate(5, Duration.HOUR),
 67        Rate(40, Duration.DAY),
 68    ]
 69
 70    _login_limiter = Limiter(_LOGIN_RATES)
 71    _home_data_limiter = Limiter(_HOME_DATA_RATES)
 72
 73    def __init__(
 74        self, username: str, base_url: str | None = None, session: aiohttp.ClientSession | None = None
 75    ) -> None:
 76        """Sample API Client."""
 77        self._username = username
 78        self._base_url = base_url
 79        self._device_identifier = secrets.token_urlsafe(16)
 80        self.session = session
 81        self._iot_login_info: IotLoginInfo | None = None
 82        self._base_urls = BASE_URLS if base_url is None else [base_url]
 83
 84    async def _get_iot_login_info(self) -> IotLoginInfo:
 85        if self._iot_login_info is None:
 86            for iot_url in self._base_urls:
 87                url_request = PreparedRequest(iot_url, self.session)
 88                response = await url_request.request(
 89                    "post",
 90                    "/api/v1/getUrlByEmail",
 91                    params={"email": self._username, "needtwostepauth": "false"},
 92                )
 93                if response is None:
 94                    continue
 95                response_code = response.get("code")
 96                if response_code != 200:
 97                    if response_code == 2003:
 98                        raise RoborockInvalidEmail("Your email was incorrectly formatted.")
 99                    elif response_code == 1001:
100                        raise RoborockMissingParameters(
101                            "You are missing parameters for this request, are you sure you entered your username?"
102                        )
103                    else:
104                        raise RoborockException(f"{response.get('msg')} - response code: {response_code}")
105                country_code = response["data"]["countrycode"]
106                country = response["data"]["country"]
107                if country_code is not None or country is not None:
108                    self._iot_login_info = IotLoginInfo(
109                        base_url=response["data"]["url"],
110                        country=country,
111                        country_code=country_code,
112                    )
113                    _LOGGER.debug("Country determined to be %s and code is %s", country, country_code)
114                    return self._iot_login_info
115            raise RoborockNoResponseFromBaseURL(
116                "No account was found for any base url we tried. Either your email is incorrect or we do not have a"
117                " record of the roborock server your device is on."
118            )
119        return self._iot_login_info
120
121    @property
122    async def base_url(self):
123        if self._base_url is not None:
124            return self._base_url
125        return (await self._get_iot_login_info()).base_url
126
127    @property
128    async def country(self):
129        return (await self._get_iot_login_info()).country
130
131    @property
132    async def country_code(self):
133        return (await self._get_iot_login_info()).country_code
134
135    def _get_header_client_id(self):
136        md5 = hashlib.md5()
137        md5.update(self._username.encode())
138        md5.update(self._device_identifier.encode())
139        return base64.b64encode(md5.digest()).decode()
140
141    async def nc_prepare(self, user_data: UserData, timezone: str) -> dict:
142        """This gets a few critical parameters for adding a device to your account."""
143        if (
144            user_data.rriot is None
145            or user_data.rriot.r is None
146            or user_data.rriot.u is None
147            or user_data.rriot.r.a is None
148        ):
149            raise RoborockException("Your userdata is missing critical attributes.")
150        base_url = user_data.rriot.r.a
151        prepare_request = PreparedRequest(base_url, self.session)
152        hid = await self._get_home_id(user_data)
153
154        data = FormData()
155        data.add_field("hid", hid)
156        data.add_field("tzid", timezone)
157
158        prepare_response = await prepare_request.request(
159            "post",
160            "/nc/prepare",
161            headers={
162                "Authorization": _get_hawk_authentication(
163                    user_data.rriot, "/nc/prepare", {"hid": hid, "tzid": timezone}
164                ),
165            },
166            data=data,
167        )
168
169        if prepare_response is None:
170            raise RoborockException("prepare_response is None")
171        if not prepare_response.get("success"):
172            raise RoborockException(f"{prepare_response.get('msg')} - response code: {prepare_response.get('code')}")
173
174        return prepare_response["result"]
175
176    async def add_device(self, user_data: UserData, s: str, t: str) -> dict:
177        """This will add a new device to your account
178        it is recommended to only use this during a pairing cycle with a device.
179        Please see here: https://github.com/Python-roborock/Roborockmitmproxy/blob/main/handshake_protocol.md
180        """
181        if (
182            user_data.rriot is None
183            or user_data.rriot.r is None
184            or user_data.rriot.u is None
185            or user_data.rriot.r.a is None
186        ):
187            raise RoborockException("Your userdata is missing critical attributes.")
188        base_url = user_data.rriot.r.a
189        add_device_request = PreparedRequest(base_url, self.session)
190
191        add_device_response = await add_device_request.request(
192            "GET",
193            "/user/devices/newadd",
194            headers={
195                "Authorization": _get_hawk_authentication(
196                    user_data.rriot, "/user/devices/newadd", params={"s": s, "t": t}
197                ),
198            },
199            params={"s": s, "t": t},
200        )
201
202        if add_device_response is None:
203            raise RoborockException("add_device is None")
204        if not add_device_response.get("success"):
205            raise RoborockException(
206                f"{add_device_response.get('msg')} - response code: {add_device_response.get('code')}"
207            )
208
209        return add_device_response["result"]
210
211    async def request_code(self) -> None:
212        if not await self._login_limiter.try_acquire_async("login", blocking=True, timeout=1):
213            raise RoborockRateLimit("Reached maximum requests for login. Please try again later.")
214        base_url = await self.base_url
215        header_clientid = self._get_header_client_id()
216        code_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
217
218        code_response = await code_request.request(
219            "post",
220            "/api/v1/sendEmailCode",
221            params={
222                "username": self._username,
223                "type": "auth",
224            },
225        )
226        if code_response is None:
227            raise RoborockException("Failed to get a response from send email code")
228        response_code = code_response.get("code")
229        if response_code != 200:
230            _LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response)
231            if response_code == 2008:
232                raise RoborockAccountDoesNotExist("Account does not exist - check your login and try again.")
233            elif response_code == 9002:
234                raise RoborockTooFrequentCodeRequests("You have attempted to request too many codes. Try again later")
235            else:
236                raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}")
237
238    async def request_code_v4(self) -> None:
239        """Request a code using the v4 endpoint."""
240        if await self.country_code is None or await self.country is None:
241            _LOGGER.info("No country code or country found, trying old version of request code.")
242            return await self.request_code()
243        if not await self._login_limiter.try_acquire_async("login", blocking=True, timeout=1):
244            raise RoborockRateLimit("Reached maximum requests for login. Please try again later.")
245        base_url = await self.base_url
246        header_clientid = self._get_header_client_id()
247        code_request = PreparedRequest(
248            base_url,
249            self.session,
250            {
251                "header_clientid": header_clientid,
252                "Content-Type": "application/x-www-form-urlencoded",
253                "header_clientlang": "en",
254            },
255        )
256
257        code_response = await code_request.request(
258            "post",
259            "/api/v4/email/code/send",
260            data={"email": self._username, "type": "login", "platform": ""},
261        )
262        if code_response is None:
263            raise RoborockException("Failed to get a response from send email code")
264        response_code = code_response.get("code")
265        if response_code != 200:
266            _LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response)
267            if response_code == 2008:
268                raise RoborockAccountDoesNotExist("Account does not exist - check your login and try again.")
269            elif response_code == 9002:
270                raise RoborockTooFrequentCodeRequests("You have attempted to request too many codes. Try again later")
271            elif response_code == 3030 and len(self._base_urls) > 1:
272                self._base_urls = self._base_urls[1:]
273                self._iot_login_info = None
274                return await self.request_code_v4()
275            else:
276                raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}")
277
278    async def _sign_key_v3(self, s: str) -> str:
279        """Sign a randomly generated string."""
280        base_url = await self.base_url
281        header_clientid = self._get_header_client_id()
282        code_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
283
284        code_response = await code_request.request(
285            "post",
286            "/api/v3/key/sign",
287            params={"s": s},
288        )
289
290        if not code_response or "data" not in code_response or "k" not in code_response["data"]:
291            raise RoborockException("Failed to get a response from sign key")
292        response_code = code_response.get("code")
293
294        if response_code != 200:
295            _LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response)
296            raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}")
297
298        return code_response["data"]["k"]
299
300    async def _get_agreement_version(self, country: str) -> dict[str, int]:
301        """Get the latest user agreement version for the given country.
302
303        The login endpoint rejects a stale agreement version with code 3006. The
304        current version differs per server and per country, so it must be looked
305        up rather than hardcoded. Falls back to the previously hardcoded values
306        if the lookup fails so that login is never blocked by this request.
307        """
308        try:
309            base_url = await self.base_url
310            agreement_request = PreparedRequest(base_url, self.session, {"header_clientlang": "en"})
311            response = await agreement_request.request(
312                "get",
313                "/api/v3/app/agreement/latest",
314                params={"country": country},
315            )
316            if response is not None and response.get("code") == 200:
317                data = response.get("data") or {}
318                major = data.get("majorVersion")
319                minor = data.get("minorVersion")
320                if isinstance(major, int) and isinstance(minor, int):
321                    _LOGGER.debug("Using user agreement version %s.%s for %s", major, minor, country)
322                    return {"majorVersion": major, "minorVersion": minor}
323            _LOGGER.debug("Unexpected agreement version response: %s", response)
324        except RoborockException as err:
325            _LOGGER.debug("Could not fetch latest user agreement version: %s", err)
326        return {"majorVersion": DEFAULT_AGREEMENT_MAJOR_VERSION, "minorVersion": DEFAULT_AGREEMENT_MINOR_VERSION}
327
328    async def code_login_v4(
329        self, code: int | str, country: str | None = None, country_code: int | None = None
330    ) -> UserData:
331        """
332        Login via code authentication.
333        :param code: The code from the email.
334        :param country: The two-character representation of the country, i.e. "US"
335        :param country_code: the country phone number code i.e. 1 for US.
336        """
337        base_url = await self.base_url
338        if country is None:
339            country = await self.country
340        if country_code is None:
341            country_code = await self.country_code
342        if country_code is None or country is None:
343            _LOGGER.info("No country code or country found, trying old version of code login.")
344            return await self.code_login(code)
345        header_clientid = self._get_header_client_id()
346        x_mercy_ks = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
347        x_mercy_k = await self._sign_key_v3(x_mercy_ks)
348        login_request = PreparedRequest(
349            base_url,
350            self.session,
351            {
352                "header_clientid": header_clientid,
353                "x-mercy-ks": x_mercy_ks,
354                "x-mercy-k": x_mercy_k,
355                "Content-Type": "application/x-www-form-urlencoded",
356                "header_clientlang": "en",
357                "header_appversion": "4.54.02",
358                "header_phonesystem": "iOS",
359                "header_phonemodel": "iPhone16,1",
360            },
361        )
362        login_response = await login_request.request(
363            "post",
364            "/api/v4/auth/email/login/code",
365            data={
366                "country": country,
367                "countryCode": country_code,
368                "email": self._username,
369                "code": code,
370                **await self._get_agreement_version(country),
371            },
372        )
373        if login_response is None:
374            raise RoborockException("Login request response is None")
375        response_code = login_response.get("code")
376        if response_code != 200:
377            _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response)
378            if response_code == 2018:
379                raise RoborockInvalidCode("Invalid code - check your code and try again.")
380            if response_code == 3009:
381                raise RoborockNoUserAgreement("You must accept the user agreement in the Roborock app to continue.")
382            if response_code == 3006:
383                raise RoborockInvalidUserAgreement(
384                    "User agreement must be accepted again - or you are attempting to use the Mi Home app account."
385                )
386            if response_code == 3039:
387                raise RoborockAccountDoesNotExist(
388                    "This account does not exist - please ensure that you selected the right region and email."
389                )
390            raise RoborockException(f"{login_response.get('msg')} - response code: {response_code}")
391        user_data = login_response.get("data")
392        if not isinstance(user_data, dict):
393            raise RoborockException("Got unexpected data type for user_data")
394        return UserData.from_dict(user_data)
395
396    async def pass_login(self, password: str) -> UserData:
397        if not await self._login_limiter.try_acquire_async("login", blocking=True, timeout=1):
398            raise RoborockRateLimit("Reached maximum requests for login. Please try again later.")
399        base_url = await self.base_url
400        header_clientid = self._get_header_client_id()
401
402        login_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
403        login_response = await login_request.request(
404            "post",
405            "/api/v1/login",
406            params={
407                "username": self._username,
408                "password": password,
409                "needtwostepauth": "false",
410            },
411        )
412        if login_response is None:
413            raise RoborockException("Login response is none")
414        if login_response.get("code") != 200:
415            _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response)
416            raise RoborockException(f"{login_response.get('msg')} - response code: {login_response.get('code')}")
417        user_data = login_response.get("data")
418        if not isinstance(user_data, dict):
419            raise RoborockException("Got unexpected data type for user_data")
420        return UserData.from_dict(user_data)
421
422    async def pass_login_v3(self, password: str) -> UserData:
423        """Seemingly it follows the format below, but password is encrypted in some manner.
424        # login_response = await login_request.request(
425        #     "post",
426        #     "/api/v3/auth/email/login",
427        #     params={
428        #         "email": self._username,
429        #         "password": password,
430        #         "twoStep": 1,
431        #         "version": 0
432        #     },
433        # )
434        """
435        raise NotImplementedError("Pass_login_v3 has not yet been implemented")
436
437    async def code_login(self, code: int | str) -> UserData:
438        base_url = await self.base_url
439        header_clientid = self._get_header_client_id()
440
441        login_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
442        login_response = await login_request.request(
443            "post",
444            "/api/v1/loginWithCode",
445            params={
446                "username": self._username,
447                "verifycode": code,
448                "verifycodetype": "AUTH_EMAIL_CODE",
449            },
450        )
451        if login_response is None:
452            raise RoborockException("Login request response is None")
453        response_code = login_response.get("code")
454        if response_code != 200:
455            _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response)
456            if response_code == 2018:
457                raise RoborockInvalidCode("Invalid code - check your code and try again.")
458            if response_code == 3009:
459                raise RoborockNoUserAgreement("You must accept the user agreement in the Roborock app to continue.")
460            if response_code == 3006:
461                raise RoborockInvalidUserAgreement(
462                    "User agreement must be accepted again - or you are attempting to use the Mi Home app account."
463                )
464            raise RoborockException(f"{login_response.get('msg')} - response code: {response_code}")
465        user_data = login_response.get("data")
466        if not isinstance(user_data, dict):
467            raise RoborockException("Got unexpected data type for user_data")
468        return UserData.from_dict(user_data)
469
470    async def _get_home_id(self, user_data: UserData):
471        base_url = await self.base_url
472        header_clientid = self._get_header_client_id()
473        home_id_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
474        home_id_response = await home_id_request.request(
475            "get",
476            "/api/v1/getHomeDetail",
477            headers={"Authorization": user_data.token},
478        )
479        if home_id_response is None:
480            raise RoborockException("home_id_response is None")
481        if home_id_response.get("code") != 200:
482            _LOGGER.info("Get Home Id failed with the following context: %s", home_id_response)
483            if home_id_response.get("code") == 2010:
484                raise RoborockInvalidCredentials(
485                    f"Invalid credentials ({home_id_response.get('msg')}) - check your login and try again."
486                )
487            raise RoborockException(f"{home_id_response.get('msg')} - response code: {home_id_response.get('code')}")
488
489        return home_id_response["data"]["rrHomeId"]
490
491    async def get_home_data(self, user_data: UserData) -> HomeData:
492        if not self._home_data_limiter.try_acquire("home_data", blocking=False):
493            raise RoborockRateLimit("Reached maximum requests for home data. Please try again later.")
494        rriot = user_data.rriot
495        if rriot is None:
496            raise RoborockException("rriot is none")
497        home_id = await self._get_home_id(user_data)
498        if rriot.r.a is None:
499            raise RoborockException("Missing field 'a' in rriot reference")
500        home_request = PreparedRequest(
501            rriot.r.a,
502            self.session,
503            {
504                "Authorization": _get_hawk_authentication(rriot, f"/user/homes/{str(home_id)}"),
505            },
506        )
507        home_response = await home_request.request("get", "/user/homes/" + str(home_id))
508        if not home_response.get("success"):
509            raise RoborockException(home_response)
510        home_data = home_response.get("result")
511        if isinstance(home_data, dict):
512            return HomeData.from_dict(home_data)
513        else:
514            raise RoborockException("home_response result was an unexpected type")
515
516    async def get_home_data_v2(self, user_data: UserData) -> HomeData:
517        """This is the same as get_home_data, but uses a different endpoint and includes non-robotic vacuums."""
518        if not self._home_data_limiter.try_acquire("home_data", blocking=False):
519            raise RoborockRateLimit("Reached maximum requests for home data. Please try again later.")
520        rriot = user_data.rriot
521        if rriot is None:
522            raise RoborockException("rriot is none")
523        home_id = await self._get_home_id(user_data)
524        if rriot.r.a is None:
525            raise RoborockException("Missing field 'a' in rriot reference")
526        home_request = PreparedRequest(
527            rriot.r.a,
528            self.session,
529            {
530                "Authorization": _get_hawk_authentication(rriot, "/v2/user/homes/" + str(home_id)),
531            },
532        )
533        home_response = await home_request.request("get", "/v2/user/homes/" + str(home_id))
534        if not home_response.get("success"):
535            raise RoborockException(home_response)
536        home_data = home_response.get("result")
537        if isinstance(home_data, dict):
538            return HomeData.from_dict(home_data)
539        else:
540            raise RoborockException("home_response result was an unexpected type")
541
542    async def get_home_data_v3(self, user_data: UserData) -> HomeData:
543        """This is the same as get_home_data, but uses a different endpoint and includes non-robotic vacuums."""
544        if not self._home_data_limiter.try_acquire("home_data", blocking=False):
545            raise RoborockRateLimit("Reached maximum requests for home data. Please try again later.")
546        rriot = user_data.rriot
547        home_id = await self._get_home_id(user_data)
548        if rriot.r.a is None:
549            raise RoborockException("Missing field 'a' in rriot reference")
550        home_request = PreparedRequest(
551            rriot.r.a,
552            self.session,
553            {
554                "Authorization": _get_hawk_authentication(rriot, "/v3/user/homes/" + str(home_id)),
555            },
556        )
557        home_response = await home_request.request("get", "/v3/user/homes/" + str(home_id))
558        if not home_response.get("success"):
559            raise RoborockException(home_response)
560        home_data = home_response.get("result")
561        if isinstance(home_data, dict):
562            return HomeData.from_dict(home_data)
563        raise RoborockException(f"home_response result was an unexpected type: {home_data}")
564
565    async def get_rooms(self, user_data: UserData, home_id: int | None = None) -> list[HomeDataRoom]:
566        rriot = user_data.rriot
567        if rriot is None:
568            raise RoborockException("rriot is none")
569        if home_id is None:
570            home_id = await self._get_home_id(user_data)
571        if rriot.r.a is None:
572            raise RoborockException("Missing field 'a' in rriot reference")
573        room_request = PreparedRequest(
574            rriot.r.a,
575            self.session,
576            {
577                "Authorization": _get_hawk_authentication(rriot, f"/user/homes/{home_id}/rooms"),
578            },
579        )
580        room_response = await room_request.request("get", f"/user/homes/{home_id}/rooms")
581        if not room_response.get("success"):
582            raise RoborockException(room_response)
583        rooms = room_response.get("result")
584        if isinstance(rooms, list):
585            output_list = []
586            for room in rooms:
587                output_list.append(HomeDataRoom.from_dict(room))
588            return output_list
589        else:
590            raise RoborockException("home_response result was an unexpected type")
591
592    async def get_shared_device_rooms(self, user_data: UserData, device_id: str) -> list[HomeDataRoom]:
593        """Fetch room names for a shared (received) device."""
594        rriot = user_data.rriot
595        if rriot is None:
596            raise RoborockException("rriot is none")
597        if rriot.r.a is None:
598            raise RoborockException("Missing field 'a' in rriot reference")
599        path = f"/user/deviceshare/query/{device_id}/rooms"
600        room_request = PreparedRequest(
601            rriot.r.a,
602            self.session,
603            {"Authorization": _get_hawk_authentication(rriot, path)},
604        )
605        room_response = await room_request.request("get", path)
606        if not room_response.get("success"):
607            raise RoborockException(room_response)
608        rooms = room_response.get("result")
609        if isinstance(rooms, list):
610            output_list = []
611            for room in rooms:
612                normalized_room = room
613                if isinstance(room, dict) and "id" not in room and "roomId" in room:
614                    normalized_room = {**room, "id": room["roomId"]}
615                output_list.append(HomeDataRoom.from_dict(normalized_room))
616            return output_list
617        raise RoborockException("get_shared_device_rooms result was an unexpected type")
618
619    async def get_scenes(self, user_data: UserData, device_id: str) -> list[HomeDataScene]:
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        scenes_request = PreparedRequest(
626            rriot.r.a,
627            self.session,
628            {
629                "Authorization": _get_hawk_authentication(rriot, f"/user/scene/device/{str(device_id)}"),
630            },
631        )
632        scenes_response = await scenes_request.request("get", f"/user/scene/device/{str(device_id)}")
633        if not scenes_response.get("success"):
634            raise RoborockException(scenes_response)
635        scenes = scenes_response.get("result")
636        if isinstance(scenes, list):
637            return [HomeDataScene.from_dict(scene) for scene in scenes]
638        else:
639            raise RoborockException("scene_response result was an unexpected type")
640
641    async def get_firmware_info(self, user_data: UserData, device_id: str) -> FirmwareInfo:
642        """Get firmware/OTA info for a device (latest version + updatable flag)."""
643        rriot = user_data.rriot
644        if rriot is None:
645            raise RoborockException("rriot is none")
646        if rriot.r.a is None:
647            raise RoborockException("Missing field 'a' in rriot reference")
648        path = f"/ota/firmware/{device_id}/updatev2"
649        params = {"lang": "en"}
650        firmware_request = PreparedRequest(
651            rriot.r.a,
652            self.session,
653            {
654                "Authorization": _get_hawk_authentication(rriot, path, params=params),
655            },
656        )
657        firmware_response = await firmware_request.request("get", path, params=params)
658        if not firmware_response or not firmware_response.get("success"):
659            raise RoborockException(firmware_response)
660        return FirmwareInfo.from_dict(firmware_response.get("result") or {})
661
662    async def start_firmware_update(self, user_data: UserData, device_id: str) -> None:
663        """Trigger the (irreversible) firmware update for a device.
664
665        The device downloads and flashes the latest firmware reported by
666        :meth:`get_firmware_info`.
667        """
668        rriot = user_data.rriot
669        if rriot is None:
670            raise RoborockException("rriot is none")
671        if rriot.r.a is None:
672            raise RoborockException("Missing field 'a' in rriot reference")
673        path = f"/ota/device/{device_id}/upgrade"
674        upgrade_request = PreparedRequest(
675            rriot.r.a,
676            self.session,
677            {
678                "Authorization": _get_hawk_authentication(rriot, path),
679            },
680        )
681        upgrade_response = await upgrade_request.request("post", path)
682        if not upgrade_response or not upgrade_response.get("success"):
683            raise RoborockException(upgrade_response)
684
685    async def set_silent_ota(self, user_data: UserData, device_id: str, enabled: bool) -> None:
686        """Enable/disable automatic (silent) firmware updates for a device.
687
688        The current value is reported as ``silent_ota_switch`` on the home-data device.
689        """
690        rriot = user_data.rriot
691        if rriot is None:
692            raise RoborockException("rriot is none")
693        if rriot.r.a is None:
694            raise RoborockException("Missing field 'a' in rriot reference")
695        path = f"/user/devices/{device_id}"
696        formdata = {"silentOtaSwitch": "true" if enabled else "false"}
697        silent_ota_request = PreparedRequest(
698            rriot.r.a,
699            self.session,
700            {
701                "Authorization": _get_hawk_authentication(rriot, path, formdata=formdata),
702            },
703        )
704        silent_ota_response = await silent_ota_request.request("put", path, data=formdata)
705        if not silent_ota_response or not silent_ota_response.get("success"):
706            raise RoborockException(silent_ota_response)
707
708    async def execute_scene(self, user_data: UserData, scene_id: int) -> None:
709        rriot = user_data.rriot
710        if rriot is None:
711            raise RoborockException("rriot is none")
712        if rriot.r.a is None:
713            raise RoborockException("Missing field 'a' in rriot reference")
714        execute_scene_request = PreparedRequest(
715            rriot.r.a,
716            self.session,
717            {
718                "Authorization": _get_hawk_authentication(rriot, f"/user/scene/{str(scene_id)}/execute"),
719            },
720        )
721        execute_scene_response = await execute_scene_request.request("POST", f"/user/scene/{str(scene_id)}/execute")
722        if not execute_scene_response.get("success"):
723            raise RoborockException(execute_scene_response)
724
725    async def get_schedules(self, user_data: UserData, device_id: str) -> list[HomeDataSchedule]:
726        rriot = user_data.rriot
727        if rriot is None:
728            raise RoborockException("rriot is none")
729        if rriot.r.a is None:
730            raise RoborockException("Missing field 'a' in rriot reference")
731        schedules_request = PreparedRequest(
732            rriot.r.a,
733            self.session,
734            {
735                "Authorization": _get_hawk_authentication(rriot, f"/user/devices/{device_id}/jobs"),
736            },
737        )
738        schedules_response = await schedules_request.request("get", f"/user/devices/{str(device_id)}/jobs")
739        if not schedules_response.get("success"):
740            raise RoborockException(schedules_response)
741        schedules = schedules_response.get("result")
742        if isinstance(schedules, list):
743            return [HomeDataSchedule.from_dict(schedule) for schedule in schedules]
744        else:
745            raise RoborockException(f"schedule_response result was an unexpected type: {schedules}")
746
747    async def create_job(self, user_data: UserData, device_id: str, job: dict) -> dict:
748        """Create a /jobs entry (schedule or one-time room clean) on a B01 device.
749
750        Body-bearing writes must sign the request body in the Hawk payload slot and send those same
751        compact bytes via ``data=``; ``json=`` would re-serialize with spaces and break the MAC.
752        """
753        rriot = user_data.rriot
754        if rriot is None:
755            raise RoborockException("rriot is none")
756        if rriot.r.a is None:
757            raise RoborockException("Missing field 'a' in rriot reference")
758        path = f"/user/devices/{device_id}/jobs"
759        job_request = PreparedRequest(
760            rriot.r.a,
761            self.session,
762            {
763                "Authorization": _get_hawk_authentication(rriot, path, body=job),
764                "Content-Type": "application/json",
765            },
766        )
767        response = await job_request.request("post", path, data=_compact_json(job).encode())
768        if not response.get("success"):
769            raise RoborockException(response)
770        return response
771
772    async def get_products(self, user_data: UserData) -> ProductResponse:
773        """Gets all products and their schemas, good for determining status codes and model numbers."""
774        base_url = await self.base_url
775        header_clientid = self._get_header_client_id()
776        product_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
777        product_response = await product_request.request(
778            "get",
779            "/api/v4/product",
780            headers={"Authorization": user_data.token},
781        )
782        if product_response is None:
783            raise RoborockException("home_id_response is None")
784        if product_response.get("code") != 200:
785            raise RoborockException(f"{product_response.get('msg')} - response code: {product_response.get('code')}")
786        result = product_response.get("data")
787        if isinstance(result, dict):
788            return ProductResponse.from_dict(result)
789        raise RoborockException("product result was an unexpected type")
790
791    async def download_code(self, user_data: UserData, product_id: int):
792        base_url = await self.base_url
793        header_clientid = self._get_header_client_id()
794        product_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
795        request = {"apilevel": 99999, "productids": [product_id], "type": 2}
796        response = await product_request.request(
797            "post",
798            "/api/v1/appplugin",
799            json=request,
800            headers={"Authorization": user_data.token, "Content-Type": "application/json"},
801        )
802        return response["data"][0]["url"]
803
804    async def download_category_code(self, user_data: UserData):
805        base_url = await self.base_url
806        header_clientid = self._get_header_client_id()
807        product_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
808        response = await product_request.request(
809            "get",
810            "api/v1/plugins?apiLevel=99999&type=2",
811            headers={
812                "Authorization": user_data.token,
813            },
814        )
815        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)
73    def __init__(
74        self, username: str, base_url: str | None = None, session: aiohttp.ClientSession | None = None
75    ) -> None:
76        """Sample API Client."""
77        self._username = username
78        self._base_url = base_url
79        self._device_identifier = secrets.token_urlsafe(16)
80        self.session = session
81        self._iot_login_info: IotLoginInfo | None = None
82        self._base_urls = BASE_URLS if base_url is None else [base_url]

Sample API Client.

session
base_url
121    @property
122    async def base_url(self):
123        if self._base_url is not None:
124            return self._base_url
125        return (await self._get_iot_login_info()).base_url
country
127    @property
128    async def country(self):
129        return (await self._get_iot_login_info()).country
country_code
131    @property
132    async def country_code(self):
133        return (await self._get_iot_login_info()).country_code
async def nc_prepare( self, user_data: roborock.data.containers.UserData, timezone: str) -> dict:
141    async def nc_prepare(self, user_data: UserData, timezone: str) -> dict:
142        """This gets a few critical parameters for adding a device to your account."""
143        if (
144            user_data.rriot is None
145            or user_data.rriot.r is None
146            or user_data.rriot.u is None
147            or user_data.rriot.r.a is None
148        ):
149            raise RoborockException("Your userdata is missing critical attributes.")
150        base_url = user_data.rriot.r.a
151        prepare_request = PreparedRequest(base_url, self.session)
152        hid = await self._get_home_id(user_data)
153
154        data = FormData()
155        data.add_field("hid", hid)
156        data.add_field("tzid", timezone)
157
158        prepare_response = await prepare_request.request(
159            "post",
160            "/nc/prepare",
161            headers={
162                "Authorization": _get_hawk_authentication(
163                    user_data.rriot, "/nc/prepare", {"hid": hid, "tzid": timezone}
164                ),
165            },
166            data=data,
167        )
168
169        if prepare_response is None:
170            raise RoborockException("prepare_response is None")
171        if not prepare_response.get("success"):
172            raise RoborockException(f"{prepare_response.get('msg')} - response code: {prepare_response.get('code')}")
173
174        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:
176    async def add_device(self, user_data: UserData, s: str, t: str) -> dict:
177        """This will add a new device to your account
178        it is recommended to only use this during a pairing cycle with a device.
179        Please see here: https://github.com/Python-roborock/Roborockmitmproxy/blob/main/handshake_protocol.md
180        """
181        if (
182            user_data.rriot is None
183            or user_data.rriot.r is None
184            or user_data.rriot.u is None
185            or user_data.rriot.r.a is None
186        ):
187            raise RoborockException("Your userdata is missing critical attributes.")
188        base_url = user_data.rriot.r.a
189        add_device_request = PreparedRequest(base_url, self.session)
190
191        add_device_response = await add_device_request.request(
192            "GET",
193            "/user/devices/newadd",
194            headers={
195                "Authorization": _get_hawk_authentication(
196                    user_data.rriot, "/user/devices/newadd", params={"s": s, "t": t}
197                ),
198            },
199            params={"s": s, "t": t},
200        )
201
202        if add_device_response is None:
203            raise RoborockException("add_device is None")
204        if not add_device_response.get("success"):
205            raise RoborockException(
206                f"{add_device_response.get('msg')} - response code: {add_device_response.get('code')}"
207            )
208
209        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:
211    async def request_code(self) -> None:
212        if not await self._login_limiter.try_acquire_async("login", blocking=True, timeout=1):
213            raise RoborockRateLimit("Reached maximum requests for login. Please try again later.")
214        base_url = await self.base_url
215        header_clientid = self._get_header_client_id()
216        code_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
217
218        code_response = await code_request.request(
219            "post",
220            "/api/v1/sendEmailCode",
221            params={
222                "username": self._username,
223                "type": "auth",
224            },
225        )
226        if code_response is None:
227            raise RoborockException("Failed to get a response from send email code")
228        response_code = code_response.get("code")
229        if response_code != 200:
230            _LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response)
231            if response_code == 2008:
232                raise RoborockAccountDoesNotExist("Account does not exist - check your login and try again.")
233            elif response_code == 9002:
234                raise RoborockTooFrequentCodeRequests("You have attempted to request too many codes. Try again later")
235            else:
236                raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}")
async def request_code_v4(self) -> None:
238    async def request_code_v4(self) -> None:
239        """Request a code using the v4 endpoint."""
240        if await self.country_code is None or await self.country is None:
241            _LOGGER.info("No country code or country found, trying old version of request code.")
242            return await self.request_code()
243        if not await self._login_limiter.try_acquire_async("login", blocking=True, timeout=1):
244            raise RoborockRateLimit("Reached maximum requests for login. Please try again later.")
245        base_url = await self.base_url
246        header_clientid = self._get_header_client_id()
247        code_request = PreparedRequest(
248            base_url,
249            self.session,
250            {
251                "header_clientid": header_clientid,
252                "Content-Type": "application/x-www-form-urlencoded",
253                "header_clientlang": "en",
254            },
255        )
256
257        code_response = await code_request.request(
258            "post",
259            "/api/v4/email/code/send",
260            data={"email": self._username, "type": "login", "platform": ""},
261        )
262        if code_response is None:
263            raise RoborockException("Failed to get a response from send email code")
264        response_code = code_response.get("code")
265        if response_code != 200:
266            _LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response)
267            if response_code == 2008:
268                raise RoborockAccountDoesNotExist("Account does not exist - check your login and try again.")
269            elif response_code == 9002:
270                raise RoborockTooFrequentCodeRequests("You have attempted to request too many codes. Try again later")
271            elif response_code == 3030 and len(self._base_urls) > 1:
272                self._base_urls = self._base_urls[1:]
273                self._iot_login_info = None
274                return await self.request_code_v4()
275            else:
276                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:
328    async def code_login_v4(
329        self, code: int | str, country: str | None = None, country_code: int | None = None
330    ) -> UserData:
331        """
332        Login via code authentication.
333        :param code: The code from the email.
334        :param country: The two-character representation of the country, i.e. "US"
335        :param country_code: the country phone number code i.e. 1 for US.
336        """
337        base_url = await self.base_url
338        if country is None:
339            country = await self.country
340        if country_code is None:
341            country_code = await self.country_code
342        if country_code is None or country is None:
343            _LOGGER.info("No country code or country found, trying old version of code login.")
344            return await self.code_login(code)
345        header_clientid = self._get_header_client_id()
346        x_mercy_ks = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
347        x_mercy_k = await self._sign_key_v3(x_mercy_ks)
348        login_request = PreparedRequest(
349            base_url,
350            self.session,
351            {
352                "header_clientid": header_clientid,
353                "x-mercy-ks": x_mercy_ks,
354                "x-mercy-k": x_mercy_k,
355                "Content-Type": "application/x-www-form-urlencoded",
356                "header_clientlang": "en",
357                "header_appversion": "4.54.02",
358                "header_phonesystem": "iOS",
359                "header_phonemodel": "iPhone16,1",
360            },
361        )
362        login_response = await login_request.request(
363            "post",
364            "/api/v4/auth/email/login/code",
365            data={
366                "country": country,
367                "countryCode": country_code,
368                "email": self._username,
369                "code": code,
370                **await self._get_agreement_version(country),
371            },
372        )
373        if login_response is None:
374            raise RoborockException("Login request response is None")
375        response_code = login_response.get("code")
376        if response_code != 200:
377            _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response)
378            if response_code == 2018:
379                raise RoborockInvalidCode("Invalid code - check your code and try again.")
380            if response_code == 3009:
381                raise RoborockNoUserAgreement("You must accept the user agreement in the Roborock app to continue.")
382            if response_code == 3006:
383                raise RoborockInvalidUserAgreement(
384                    "User agreement must be accepted again - or you are attempting to use the Mi Home app account."
385                )
386            if response_code == 3039:
387                raise RoborockAccountDoesNotExist(
388                    "This account does not exist - please ensure that you selected the right region and email."
389                )
390            raise RoborockException(f"{login_response.get('msg')} - response code: {response_code}")
391        user_data = login_response.get("data")
392        if not isinstance(user_data, dict):
393            raise RoborockException("Got unexpected data type for user_data")
394        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:
396    async def pass_login(self, password: str) -> UserData:
397        if not await self._login_limiter.try_acquire_async("login", blocking=True, timeout=1):
398            raise RoborockRateLimit("Reached maximum requests for login. Please try again later.")
399        base_url = await self.base_url
400        header_clientid = self._get_header_client_id()
401
402        login_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
403        login_response = await login_request.request(
404            "post",
405            "/api/v1/login",
406            params={
407                "username": self._username,
408                "password": password,
409                "needtwostepauth": "false",
410            },
411        )
412        if login_response is None:
413            raise RoborockException("Login response is none")
414        if login_response.get("code") != 200:
415            _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response)
416            raise RoborockException(f"{login_response.get('msg')} - response code: {login_response.get('code')}")
417        user_data = login_response.get("data")
418        if not isinstance(user_data, dict):
419            raise RoborockException("Got unexpected data type for user_data")
420        return UserData.from_dict(user_data)
async def pass_login_v3(self, password: str) -> roborock.data.containers.UserData:
422    async def pass_login_v3(self, password: str) -> UserData:
423        """Seemingly it follows the format below, but password is encrypted in some manner.
424        # login_response = await login_request.request(
425        #     "post",
426        #     "/api/v3/auth/email/login",
427        #     params={
428        #         "email": self._username,
429        #         "password": password,
430        #         "twoStep": 1,
431        #         "version": 0
432        #     },
433        # )
434        """
435        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:
437    async def code_login(self, code: int | str) -> UserData:
438        base_url = await self.base_url
439        header_clientid = self._get_header_client_id()
440
441        login_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
442        login_response = await login_request.request(
443            "post",
444            "/api/v1/loginWithCode",
445            params={
446                "username": self._username,
447                "verifycode": code,
448                "verifycodetype": "AUTH_EMAIL_CODE",
449            },
450        )
451        if login_response is None:
452            raise RoborockException("Login request response is None")
453        response_code = login_response.get("code")
454        if response_code != 200:
455            _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response)
456            if response_code == 2018:
457                raise RoborockInvalidCode("Invalid code - check your code and try again.")
458            if response_code == 3009:
459                raise RoborockNoUserAgreement("You must accept the user agreement in the Roborock app to continue.")
460            if response_code == 3006:
461                raise RoborockInvalidUserAgreement(
462                    "User agreement must be accepted again - or you are attempting to use the Mi Home app account."
463                )
464            raise RoborockException(f"{login_response.get('msg')} - response code: {response_code}")
465        user_data = login_response.get("data")
466        if not isinstance(user_data, dict):
467            raise RoborockException("Got unexpected data type for user_data")
468        return UserData.from_dict(user_data)
async def get_home_data( self, user_data: roborock.data.containers.UserData) -> roborock.data.containers.HomeData:
491    async def get_home_data(self, user_data: UserData) -> HomeData:
492        if not self._home_data_limiter.try_acquire("home_data", blocking=False):
493            raise RoborockRateLimit("Reached maximum requests for home data. Please try again later.")
494        rriot = user_data.rriot
495        if rriot is None:
496            raise RoborockException("rriot is none")
497        home_id = await self._get_home_id(user_data)
498        if rriot.r.a is None:
499            raise RoborockException("Missing field 'a' in rriot reference")
500        home_request = PreparedRequest(
501            rriot.r.a,
502            self.session,
503            {
504                "Authorization": _get_hawk_authentication(rriot, f"/user/homes/{str(home_id)}"),
505            },
506        )
507        home_response = await home_request.request("get", "/user/homes/" + str(home_id))
508        if not home_response.get("success"):
509            raise RoborockException(home_response)
510        home_data = home_response.get("result")
511        if isinstance(home_data, dict):
512            return HomeData.from_dict(home_data)
513        else:
514            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:
516    async def get_home_data_v2(self, user_data: UserData) -> HomeData:
517        """This is the same as get_home_data, but uses a different endpoint and includes non-robotic vacuums."""
518        if not self._home_data_limiter.try_acquire("home_data", blocking=False):
519            raise RoborockRateLimit("Reached maximum requests for home data. Please try again later.")
520        rriot = user_data.rriot
521        if rriot is None:
522            raise RoborockException("rriot is none")
523        home_id = await self._get_home_id(user_data)
524        if rriot.r.a is None:
525            raise RoborockException("Missing field 'a' in rriot reference")
526        home_request = PreparedRequest(
527            rriot.r.a,
528            self.session,
529            {
530                "Authorization": _get_hawk_authentication(rriot, "/v2/user/homes/" + str(home_id)),
531            },
532        )
533        home_response = await home_request.request("get", "/v2/user/homes/" + str(home_id))
534        if not home_response.get("success"):
535            raise RoborockException(home_response)
536        home_data = home_response.get("result")
537        if isinstance(home_data, dict):
538            return HomeData.from_dict(home_data)
539        else:
540            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:
542    async def get_home_data_v3(self, user_data: UserData) -> HomeData:
543        """This is the same as get_home_data, but uses a different endpoint and includes non-robotic vacuums."""
544        if not self._home_data_limiter.try_acquire("home_data", blocking=False):
545            raise RoborockRateLimit("Reached maximum requests for home data. Please try again later.")
546        rriot = user_data.rriot
547        home_id = await self._get_home_id(user_data)
548        if rriot.r.a is None:
549            raise RoborockException("Missing field 'a' in rriot reference")
550        home_request = PreparedRequest(
551            rriot.r.a,
552            self.session,
553            {
554                "Authorization": _get_hawk_authentication(rriot, "/v3/user/homes/" + str(home_id)),
555            },
556        )
557        home_response = await home_request.request("get", "/v3/user/homes/" + str(home_id))
558        if not home_response.get("success"):
559            raise RoborockException(home_response)
560        home_data = home_response.get("result")
561        if isinstance(home_data, dict):
562            return HomeData.from_dict(home_data)
563        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]:
565    async def get_rooms(self, user_data: UserData, home_id: int | None = None) -> list[HomeDataRoom]:
566        rriot = user_data.rriot
567        if rriot is None:
568            raise RoborockException("rriot is none")
569        if home_id is None:
570            home_id = await self._get_home_id(user_data)
571        if rriot.r.a is None:
572            raise RoborockException("Missing field 'a' in rriot reference")
573        room_request = PreparedRequest(
574            rriot.r.a,
575            self.session,
576            {
577                "Authorization": _get_hawk_authentication(rriot, f"/user/homes/{home_id}/rooms"),
578            },
579        )
580        room_response = await room_request.request("get", f"/user/homes/{home_id}/rooms")
581        if not room_response.get("success"):
582            raise RoborockException(room_response)
583        rooms = room_response.get("result")
584        if isinstance(rooms, list):
585            output_list = []
586            for room in rooms:
587                output_list.append(HomeDataRoom.from_dict(room))
588            return output_list
589        else:
590            raise RoborockException("home_response result was an unexpected type")
async def get_shared_device_rooms( self, user_data: roborock.data.containers.UserData, device_id: str) -> list[roborock.data.containers.HomeDataRoom]:
592    async def get_shared_device_rooms(self, user_data: UserData, device_id: str) -> list[HomeDataRoom]:
593        """Fetch room names for a shared (received) device."""
594        rriot = user_data.rriot
595        if rriot is None:
596            raise RoborockException("rriot is none")
597        if rriot.r.a is None:
598            raise RoborockException("Missing field 'a' in rriot reference")
599        path = f"/user/deviceshare/query/{device_id}/rooms"
600        room_request = PreparedRequest(
601            rriot.r.a,
602            self.session,
603            {"Authorization": _get_hawk_authentication(rriot, path)},
604        )
605        room_response = await room_request.request("get", path)
606        if not room_response.get("success"):
607            raise RoborockException(room_response)
608        rooms = room_response.get("result")
609        if isinstance(rooms, list):
610            output_list = []
611            for room in rooms:
612                normalized_room = room
613                if isinstance(room, dict) and "id" not in room and "roomId" in room:
614                    normalized_room = {**room, "id": room["roomId"]}
615                output_list.append(HomeDataRoom.from_dict(normalized_room))
616            return output_list
617        raise RoborockException("get_shared_device_rooms result was an unexpected type")

Fetch room names for a shared (received) device.

async def get_scenes( self, user_data: roborock.data.containers.UserData, device_id: str) -> list[roborock.data.containers.HomeDataScene]:
619    async def get_scenes(self, user_data: UserData, device_id: str) -> list[HomeDataScene]:
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        scenes_request = PreparedRequest(
626            rriot.r.a,
627            self.session,
628            {
629                "Authorization": _get_hawk_authentication(rriot, f"/user/scene/device/{str(device_id)}"),
630            },
631        )
632        scenes_response = await scenes_request.request("get", f"/user/scene/device/{str(device_id)}")
633        if not scenes_response.get("success"):
634            raise RoborockException(scenes_response)
635        scenes = scenes_response.get("result")
636        if isinstance(scenes, list):
637            return [HomeDataScene.from_dict(scene) for scene in scenes]
638        else:
639            raise RoborockException("scene_response result was an unexpected type")
async def get_firmware_info( self, user_data: roborock.data.containers.UserData, device_id: str) -> roborock.data.containers.FirmwareInfo:
641    async def get_firmware_info(self, user_data: UserData, device_id: str) -> FirmwareInfo:
642        """Get firmware/OTA info for a device (latest version + updatable flag)."""
643        rriot = user_data.rriot
644        if rriot is None:
645            raise RoborockException("rriot is none")
646        if rriot.r.a is None:
647            raise RoborockException("Missing field 'a' in rriot reference")
648        path = f"/ota/firmware/{device_id}/updatev2"
649        params = {"lang": "en"}
650        firmware_request = PreparedRequest(
651            rriot.r.a,
652            self.session,
653            {
654                "Authorization": _get_hawk_authentication(rriot, path, params=params),
655            },
656        )
657        firmware_response = await firmware_request.request("get", path, params=params)
658        if not firmware_response or not firmware_response.get("success"):
659            raise RoborockException(firmware_response)
660        return FirmwareInfo.from_dict(firmware_response.get("result") or {})

Get firmware/OTA info for a device (latest version + updatable flag).

async def start_firmware_update( self, user_data: roborock.data.containers.UserData, device_id: str) -> None:
662    async def start_firmware_update(self, user_data: UserData, device_id: str) -> None:
663        """Trigger the (irreversible) firmware update for a device.
664
665        The device downloads and flashes the latest firmware reported by
666        :meth:`get_firmware_info`.
667        """
668        rriot = user_data.rriot
669        if rriot is None:
670            raise RoborockException("rriot is none")
671        if rriot.r.a is None:
672            raise RoborockException("Missing field 'a' in rriot reference")
673        path = f"/ota/device/{device_id}/upgrade"
674        upgrade_request = PreparedRequest(
675            rriot.r.a,
676            self.session,
677            {
678                "Authorization": _get_hawk_authentication(rriot, path),
679            },
680        )
681        upgrade_response = await upgrade_request.request("post", path)
682        if not upgrade_response or not upgrade_response.get("success"):
683            raise RoborockException(upgrade_response)

Trigger the (irreversible) firmware update for a device.

The device downloads and flashes the latest firmware reported by get_firmware_info().

async def set_silent_ota( self, user_data: roborock.data.containers.UserData, device_id: str, enabled: bool) -> None:
685    async def set_silent_ota(self, user_data: UserData, device_id: str, enabled: bool) -> None:
686        """Enable/disable automatic (silent) firmware updates for a device.
687
688        The current value is reported as ``silent_ota_switch`` on the home-data device.
689        """
690        rriot = user_data.rriot
691        if rriot is None:
692            raise RoborockException("rriot is none")
693        if rriot.r.a is None:
694            raise RoborockException("Missing field 'a' in rriot reference")
695        path = f"/user/devices/{device_id}"
696        formdata = {"silentOtaSwitch": "true" if enabled else "false"}
697        silent_ota_request = PreparedRequest(
698            rriot.r.a,
699            self.session,
700            {
701                "Authorization": _get_hawk_authentication(rriot, path, formdata=formdata),
702            },
703        )
704        silent_ota_response = await silent_ota_request.request("put", path, data=formdata)
705        if not silent_ota_response or not silent_ota_response.get("success"):
706            raise RoborockException(silent_ota_response)

Enable/disable automatic (silent) firmware updates for a device.

The current value is reported as silent_ota_switch on the home-data device.

async def execute_scene( self, user_data: roborock.data.containers.UserData, scene_id: int) -> None:
708    async def execute_scene(self, user_data: UserData, scene_id: int) -> None:
709        rriot = user_data.rriot
710        if rriot is None:
711            raise RoborockException("rriot is none")
712        if rriot.r.a is None:
713            raise RoborockException("Missing field 'a' in rriot reference")
714        execute_scene_request = PreparedRequest(
715            rriot.r.a,
716            self.session,
717            {
718                "Authorization": _get_hawk_authentication(rriot, f"/user/scene/{str(scene_id)}/execute"),
719            },
720        )
721        execute_scene_response = await execute_scene_request.request("POST", f"/user/scene/{str(scene_id)}/execute")
722        if not execute_scene_response.get("success"):
723            raise RoborockException(execute_scene_response)
async def get_schedules( self, user_data: roborock.data.containers.UserData, device_id: str) -> list[roborock.data.containers.HomeDataSchedule]:
725    async def get_schedules(self, user_data: UserData, device_id: str) -> list[HomeDataSchedule]:
726        rriot = user_data.rriot
727        if rriot is None:
728            raise RoborockException("rriot is none")
729        if rriot.r.a is None:
730            raise RoborockException("Missing field 'a' in rriot reference")
731        schedules_request = PreparedRequest(
732            rriot.r.a,
733            self.session,
734            {
735                "Authorization": _get_hawk_authentication(rriot, f"/user/devices/{device_id}/jobs"),
736            },
737        )
738        schedules_response = await schedules_request.request("get", f"/user/devices/{str(device_id)}/jobs")
739        if not schedules_response.get("success"):
740            raise RoborockException(schedules_response)
741        schedules = schedules_response.get("result")
742        if isinstance(schedules, list):
743            return [HomeDataSchedule.from_dict(schedule) for schedule in schedules]
744        else:
745            raise RoborockException(f"schedule_response result was an unexpected type: {schedules}")
async def create_job( self, user_data: roborock.data.containers.UserData, device_id: str, job: dict) -> dict:
747    async def create_job(self, user_data: UserData, device_id: str, job: dict) -> dict:
748        """Create a /jobs entry (schedule or one-time room clean) on a B01 device.
749
750        Body-bearing writes must sign the request body in the Hawk payload slot and send those same
751        compact bytes via ``data=``; ``json=`` would re-serialize with spaces and break the MAC.
752        """
753        rriot = user_data.rriot
754        if rriot is None:
755            raise RoborockException("rriot is none")
756        if rriot.r.a is None:
757            raise RoborockException("Missing field 'a' in rriot reference")
758        path = f"/user/devices/{device_id}/jobs"
759        job_request = PreparedRequest(
760            rriot.r.a,
761            self.session,
762            {
763                "Authorization": _get_hawk_authentication(rriot, path, body=job),
764                "Content-Type": "application/json",
765            },
766        )
767        response = await job_request.request("post", path, data=_compact_json(job).encode())
768        if not response.get("success"):
769            raise RoborockException(response)
770        return response

Create a /jobs entry (schedule or one-time room clean) on a B01 device.

Body-bearing writes must sign the request body in the Hawk payload slot and send those same compact bytes via data=; json= would re-serialize with spaces and break the MAC.

async def get_products( self, user_data: roborock.data.containers.UserData) -> roborock.data.containers.ProductResponse:
772    async def get_products(self, user_data: UserData) -> ProductResponse:
773        """Gets all products and their schemas, good for determining status codes and model numbers."""
774        base_url = await self.base_url
775        header_clientid = self._get_header_client_id()
776        product_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
777        product_response = await product_request.request(
778            "get",
779            "/api/v4/product",
780            headers={"Authorization": user_data.token},
781        )
782        if product_response is None:
783            raise RoborockException("home_id_response is None")
784        if product_response.get("code") != 200:
785            raise RoborockException(f"{product_response.get('msg')} - response code: {product_response.get('code')}")
786        result = product_response.get("data")
787        if isinstance(result, dict):
788            return ProductResponse.from_dict(result)
789        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):
791    async def download_code(self, user_data: UserData, product_id: int):
792        base_url = await self.base_url
793        header_clientid = self._get_header_client_id()
794        product_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
795        request = {"apilevel": 99999, "productids": [product_id], "type": 2}
796        response = await product_request.request(
797            "post",
798            "/api/v1/appplugin",
799            json=request,
800            headers={"Authorization": user_data.token, "Content-Type": "application/json"},
801        )
802        return response["data"][0]["url"]
async def download_category_code(self, user_data: roborock.data.containers.UserData):
804    async def download_category_code(self, user_data: UserData):
805        base_url = await self.base_url
806        header_clientid = self._get_header_client_id()
807        product_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid})
808        response = await product_request.request(
809            "get",
810            "api/v1/plugins?apiLevel=99999&type=2",
811            headers={
812                "Authorization": user_data.token,
813            },
814        )
815        return {r["category"]: r["url"] for r in response["data"]["categoryPluginList"]}
class PreparedRequest:
818class PreparedRequest:
819    def __init__(
820        self, base_url: str, session: aiohttp.ClientSession | None = None, base_headers: dict | None = None
821    ) -> None:
822        self.base_url = base_url
823        self.base_headers = base_headers or {}
824        self.session = session
825
826    async def request(self, method: str, url: str, params=None, data=None, headers=None, json=None) -> dict:
827        _url = "/".join(s.strip("/") for s in [self.base_url, url])
828        _headers = {**self.base_headers, **(headers or {})}
829        close_session = self.session is None
830        session = self.session if self.session is not None else aiohttp.ClientSession()
831        try:
832            async with session.request(method, _url, params=params, data=data, headers=_headers, json=json) as resp:
833                return await resp.json()
834        except ContentTypeError as err:
835            """If we get an error, lets log everything for debugging."""
836            try:
837                resp_json = await resp.json(content_type=None)
838                _LOGGER.info("Resp: %s", resp_json)
839            except ContentTypeError as err_2:
840                _LOGGER.info(err_2)
841            resp_raw = await resp.read()
842            _LOGGER.info("Resp raw: %s", resp_raw)
843            # Still raise the err so that it's clear it failed.
844            raise err
845        except (aiohttp.ClientError, TimeoutError, OSError) as err:
846            raise RoborockException(f"Network error contacting {_url}: {err}") from err
847        finally:
848            if close_session:
849                await session.close()
PreparedRequest( base_url: str, session: aiohttp.client.ClientSession | None = None, base_headers: dict | None = None)
819    def __init__(
820        self, base_url: str, session: aiohttp.ClientSession | None = None, base_headers: dict | None = None
821    ) -> None:
822        self.base_url = base_url
823        self.base_headers = base_headers or {}
824        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:
826    async def request(self, method: str, url: str, params=None, data=None, headers=None, json=None) -> dict:
827        _url = "/".join(s.strip("/") for s in [self.base_url, url])
828        _headers = {**self.base_headers, **(headers or {})}
829        close_session = self.session is None
830        session = self.session if self.session is not None else aiohttp.ClientSession()
831        try:
832            async with session.request(method, _url, params=params, data=data, headers=_headers, json=json) as resp:
833                return await resp.json()
834        except ContentTypeError as err:
835            """If we get an error, lets log everything for debugging."""
836            try:
837                resp_json = await resp.json(content_type=None)
838                _LOGGER.info("Resp: %s", resp_json)
839            except ContentTypeError as err_2:
840                _LOGGER.info(err_2)
841            resp_raw = await resp.read()
842            _LOGGER.info("Resp raw: %s", resp_raw)
843            # Still raise the err so that it's clear it failed.
844            raise err
845        except (aiohttp.ClientError, TimeoutError, OSError) as err:
846            raise RoborockException(f"Network error contacting {_url}: {err}") from err
847        finally:
848            if close_session:
849                await session.close()
class UserWebApiClient:
899class UserWebApiClient:
900    """Wrapper around RoborockApiClient to provide information for a specific user.
901
902    This binds a RoborockApiClient to a specific user context with the
903    provided UserData. This allows for easier access to user-specific data,
904    to avoid needing to pass UserData around and mock out the web API.
905    """
906
907    def __init__(
908        self, web_api: RoborockApiClient, user_data: UserData, unauthorized_hook: Callable[[], None] | None = None
909    ) -> None:
910        """Initialize the wrapper with the API client and user data."""
911        self._web_api = web_api
912        self._user_data = user_data
913        self._unauthorized_hook = unauthorized_hook
914
915    async def get_home_data(self) -> HomeData:
916        """Fetch home data using the API client."""
917        try:
918            return await self._web_api.get_home_data_v3(self._user_data)
919        except RoborockInvalidCredentials:
920            if self._unauthorized_hook:
921                self._unauthorized_hook()
922            raise
923
924    async def get_routines(self, device_id: str) -> list[HomeDataScene]:
925        """Fetch routines (scenes) for a specific device."""
926        try:
927            return await self._web_api.get_scenes(self._user_data, device_id)
928        except RoborockInvalidCredentials:
929            if self._unauthorized_hook:
930                self._unauthorized_hook()
931            raise
932
933    async def get_rooms(self) -> list[HomeDataRoom]:
934        """Fetch rooms using the API client."""
935        try:
936            return await self._web_api.get_rooms(self._user_data)
937        except RoborockInvalidCredentials:
938            if self._unauthorized_hook:
939                self._unauthorized_hook()
940            raise
941
942    async def get_shared_device_rooms(self, device_id: str) -> list[HomeDataRoom]:
943        """Fetch shared-device rooms using the API client."""
944        return await self._web_api.get_shared_device_rooms(self._user_data, device_id)
945
946    async def execute_routine(self, scene_id: int) -> None:
947        """Execute a specific routine (scene) by its ID."""
948        try:
949            await self._web_api.execute_scene(self._user_data, scene_id)
950        except RoborockInvalidCredentials:
951            if self._unauthorized_hook:
952                self._unauthorized_hook()
953            raise

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, unauthorized_hook: Callable[[], None] | None = None)
907    def __init__(
908        self, web_api: RoborockApiClient, user_data: UserData, unauthorized_hook: Callable[[], None] | None = None
909    ) -> None:
910        """Initialize the wrapper with the API client and user data."""
911        self._web_api = web_api
912        self._user_data = user_data
913        self._unauthorized_hook = unauthorized_hook

Initialize the wrapper with the API client and user data.

async def get_home_data(self) -> roborock.data.containers.HomeData:
915    async def get_home_data(self) -> HomeData:
916        """Fetch home data using the API client."""
917        try:
918            return await self._web_api.get_home_data_v3(self._user_data)
919        except RoborockInvalidCredentials:
920            if self._unauthorized_hook:
921                self._unauthorized_hook()
922            raise

Fetch home data using the API client.

async def get_routines(self, device_id: str) -> list[roborock.data.containers.HomeDataScene]:
924    async def get_routines(self, device_id: str) -> list[HomeDataScene]:
925        """Fetch routines (scenes) for a specific device."""
926        try:
927            return await self._web_api.get_scenes(self._user_data, device_id)
928        except RoborockInvalidCredentials:
929            if self._unauthorized_hook:
930                self._unauthorized_hook()
931            raise

Fetch routines (scenes) for a specific device.

async def get_rooms(self) -> list[roborock.data.containers.HomeDataRoom]:
933    async def get_rooms(self) -> list[HomeDataRoom]:
934        """Fetch rooms using the API client."""
935        try:
936            return await self._web_api.get_rooms(self._user_data)
937        except RoborockInvalidCredentials:
938            if self._unauthorized_hook:
939                self._unauthorized_hook()
940            raise

Fetch rooms using the API client.

async def get_shared_device_rooms(self, device_id: str) -> list[roborock.data.containers.HomeDataRoom]:
942    async def get_shared_device_rooms(self, device_id: str) -> list[HomeDataRoom]:
943        """Fetch shared-device rooms using the API client."""
944        return await self._web_api.get_shared_device_rooms(self._user_data, device_id)

Fetch shared-device rooms using the API client.

async def execute_routine(self, scene_id: int) -> None:
946    async def execute_routine(self, scene_id: int) -> None:
947        """Execute a specific routine (scene) by its ID."""
948        try:
949            await self._web_api.execute_scene(self._user_data, scene_id)
950        except RoborockInvalidCredentials:
951            if self._unauthorized_hook:
952                self._unauthorized_hook()
953            raise

Execute a specific routine (scene) by its ID.