roborock.web_api

Roborock cloud REST API clients.

The public entry points are RoborockApiClient and UserWebApiClient. Endpoints are grouped by resource into submodules (auth, homes, devices, scenes, jobs, ota, products); each exposes a mixin that RoborockApiClient composes. Everything that was importable from the former roborock.web_api module is re-exported here for backwards compatibility.

 1"""Roborock cloud REST API clients.
 2
 3The public entry points are :class:`RoborockApiClient` and :class:`UserWebApiClient`.
 4Endpoints are grouped by resource into submodules (``auth``, ``homes``, ``devices``,
 5``scenes``, ``jobs``, ``ota``, ``products``); each exposes a mixin that
 6``RoborockApiClient`` composes. Everything that was importable from the former
 7``roborock.web_api`` module is re-exported here for backwards compatibility.
 8"""
 9
10from ._base import BASE_URLS, IotLoginInfo, RoborockApiBase
11from ._request import PreparedRequest
12from .auth import DEFAULT_AGREEMENT_MAJOR_VERSION, DEFAULT_AGREEMENT_MINOR_VERSION, AuthApi
13from .client import RoborockApiClient, UserWebApiClient
14from .devices import DevicesApi
15from .homes import HomesApi
16from .jobs import JobsApi
17from .ota import OtaApi
18from .products import ProductsApi
19from .scenes import ScenesApi
20
21__all__ = [
22    "BASE_URLS",
23    "DEFAULT_AGREEMENT_MAJOR_VERSION",
24    "DEFAULT_AGREEMENT_MINOR_VERSION",
25    "AuthApi",
26    "DevicesApi",
27    "HomesApi",
28    "IotLoginInfo",
29    "JobsApi",
30    "OtaApi",
31    "PreparedRequest",
32    "ProductsApi",
33    "RoborockApiBase",
34    "RoborockApiClient",
35    "ScenesApi",
36    "UserWebApiClient",
37]
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
class AuthApi(roborock.web_api.RoborockApiBase):
 31class AuthApi(RoborockApiBase):
 32    """Email/password and email-code login flows."""
 33
 34    _LOGIN_RATES = [
 35        Rate(1, Duration.SECOND),
 36        Rate(3, Duration.MINUTE),
 37        Rate(10, Duration.HOUR),
 38        Rate(20, Duration.DAY),
 39    ]
 40    _login_limiter = Limiter(_LOGIN_RATES)
 41
 42    async def request_code(self) -> None:
 43        if not await self._login_limiter.try_acquire_async("login", blocking=True, timeout=1):
 44            raise RoborockRateLimit("Reached maximum requests for login. Please try again later.")
 45        code_request = await self._iot_request()
 46
 47        code_response = await code_request.request(
 48            "post",
 49            "/api/v1/sendEmailCode",
 50            params={
 51                "username": self._username,
 52                "type": "auth",
 53            },
 54        )
 55        if code_response is None:
 56            raise RoborockException("Failed to get a response from send email code")
 57        response_code = code_response.get("code")
 58        if response_code != 200:
 59            _LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response)
 60            if response_code == 2008:
 61                raise RoborockAccountDoesNotExist("Account does not exist - check your login and try again.")
 62            elif response_code == 9002:
 63                raise RoborockTooFrequentCodeRequests("You have attempted to request too many codes. Try again later")
 64            else:
 65                raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}")
 66
 67    async def request_code_v4(self) -> None:
 68        """Request a code using the v4 endpoint."""
 69        if await self.country_code is None or await self.country is None:
 70            _LOGGER.info("No country code or country found, trying old version of request code.")
 71            return await self.request_code()
 72        if not await self._login_limiter.try_acquire_async("login", blocking=True, timeout=1):
 73            raise RoborockRateLimit("Reached maximum requests for login. Please try again later.")
 74        code_request = await self._iot_request(
 75            {
 76                "Content-Type": "application/x-www-form-urlencoded",
 77                "header_clientlang": "en",
 78            }
 79        )
 80
 81        code_response = await code_request.request(
 82            "post",
 83            "/api/v4/email/code/send",
 84            data={"email": self._username, "type": "login", "platform": ""},
 85        )
 86        if code_response is None:
 87            raise RoborockException("Failed to get a response from send email code")
 88        response_code = code_response.get("code")
 89        if response_code != 200:
 90            _LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response)
 91            if response_code == 2008:
 92                raise RoborockAccountDoesNotExist("Account does not exist - check your login and try again.")
 93            elif response_code == 9002:
 94                raise RoborockTooFrequentCodeRequests("You have attempted to request too many codes. Try again later")
 95            elif response_code == 3030 and len(self._base_urls) > 1:
 96                self._base_urls = self._base_urls[1:]
 97                self._iot_login_info = None
 98                return await self.request_code_v4()
 99            else:
100                raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}")
101
102    async def _sign_key_v3(self, s: str) -> str:
103        """Sign a randomly generated string."""
104        code_request = await self._iot_request()
105
106        code_response = await code_request.request(
107            "post",
108            "/api/v3/key/sign",
109            params={"s": s},
110        )
111
112        if not code_response or "data" not in code_response or "k" not in code_response["data"]:
113            raise RoborockException("Failed to get a response from sign key")
114        response_code = code_response.get("code")
115
116        if response_code != 200:
117            _LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response)
118            raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}")
119
120        return code_response["data"]["k"]
121
122    async def _get_agreement_version(self, country: str) -> dict[str, int]:
123        """Get the latest user agreement version for the given country.
124
125        The login endpoint rejects a stale agreement version with code 3006. The
126        current version differs per server and per country, so it must be looked
127        up rather than hardcoded. Falls back to the previously hardcoded values
128        if the lookup fails so that login is never blocked by this request.
129        """
130        try:
131            base_url = await self.base_url
132            agreement_request = PreparedRequest(base_url, self.session, {"header_clientlang": "en"})
133            response = await agreement_request.request(
134                "get",
135                "/api/v3/app/agreement/latest",
136                params={"country": country},
137            )
138            if response is not None and response.get("code") == 200:
139                data = response.get("data") or {}
140                major = data.get("majorVersion")
141                minor = data.get("minorVersion")
142                if isinstance(major, int) and isinstance(minor, int):
143                    _LOGGER.debug("Using user agreement version %s.%s for %s", major, minor, country)
144                    return {"majorVersion": major, "minorVersion": minor}
145            _LOGGER.debug("Unexpected agreement version response: %s", response)
146        except RoborockException as err:
147            _LOGGER.debug("Could not fetch latest user agreement version: %s", err)
148        return {"majorVersion": DEFAULT_AGREEMENT_MAJOR_VERSION, "minorVersion": DEFAULT_AGREEMENT_MINOR_VERSION}
149
150    async def code_login_v4(
151        self, code: int | str, country: str | None = None, country_code: int | None = None
152    ) -> UserData:
153        """
154        Login via code authentication.
155        :param code: The code from the email.
156        :param country: The two-character representation of the country, i.e. "US"
157        :param country_code: the country phone number code i.e. 1 for US.
158        """
159        if country is None:
160            country = await self.country
161        if country_code is None:
162            country_code = await self.country_code
163        if country_code is None or country is None:
164            _LOGGER.info("No country code or country found, trying old version of code login.")
165            return await self.code_login(code)
166        x_mercy_ks = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
167        x_mercy_k = await self._sign_key_v3(x_mercy_ks)
168        login_request = await self._iot_request(
169            {
170                "x-mercy-ks": x_mercy_ks,
171                "x-mercy-k": x_mercy_k,
172                "Content-Type": "application/x-www-form-urlencoded",
173                "header_clientlang": "en",
174                "header_appversion": "4.54.02",
175                "header_phonesystem": "iOS",
176                "header_phonemodel": "iPhone16,1",
177            }
178        )
179        login_response = await login_request.request(
180            "post",
181            "/api/v4/auth/email/login/code",
182            data={
183                "country": country,
184                "countryCode": country_code,
185                "email": self._username,
186                "code": code,
187                **await self._get_agreement_version(country),
188            },
189        )
190        if login_response is None:
191            raise RoborockException("Login request response is None")
192        response_code = login_response.get("code")
193        if response_code != 200:
194            _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response)
195            if response_code == 2018:
196                raise RoborockInvalidCode("Invalid code - check your code and try again.")
197            if response_code == 3009:
198                raise RoborockNoUserAgreement("You must accept the user agreement in the Roborock app to continue.")
199            if response_code == 3006:
200                raise RoborockInvalidUserAgreement(
201                    "User agreement must be accepted again - or you are attempting to use the Mi Home app account."
202                )
203            if response_code == 3039:
204                raise RoborockAccountDoesNotExist(
205                    "This account does not exist - please ensure that you selected the right region and email."
206                )
207            raise RoborockException(f"{login_response.get('msg')} - response code: {response_code}")
208        user_data = login_response.get("data")
209        if not isinstance(user_data, dict):
210            raise RoborockException("Got unexpected data type for user_data")
211        return UserData.from_dict(user_data)
212
213    async def pass_login(self, password: str) -> UserData:
214        if not await self._login_limiter.try_acquire_async("login", blocking=True, timeout=1):
215            raise RoborockRateLimit("Reached maximum requests for login. Please try again later.")
216        login_request = await self._iot_request()
217        login_response = await login_request.request(
218            "post",
219            "/api/v1/login",
220            params={
221                "username": self._username,
222                "password": password,
223                "needtwostepauth": "false",
224            },
225        )
226        if login_response is None:
227            raise RoborockException("Login response is none")
228        if login_response.get("code") != 200:
229            _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response)
230            raise RoborockException(f"{login_response.get('msg')} - response code: {login_response.get('code')}")
231        user_data = login_response.get("data")
232        if not isinstance(user_data, dict):
233            raise RoborockException("Got unexpected data type for user_data")
234        return UserData.from_dict(user_data)
235
236    async def pass_login_v3(self, password: str) -> UserData:
237        """Seemingly it follows the format below, but password is encrypted in some manner.
238        # login_response = await login_request.request(
239        #     "post",
240        #     "/api/v3/auth/email/login",
241        #     params={
242        #         "email": self._username,
243        #         "password": password,
244        #         "twoStep": 1,
245        #         "version": 0
246        #     },
247        # )
248        """
249        raise NotImplementedError("Pass_login_v3 has not yet been implemented")
250
251    async def code_login(self, code: int | str) -> UserData:
252        login_request = await self._iot_request()
253        login_response = await login_request.request(
254            "post",
255            "/api/v1/loginWithCode",
256            params={
257                "username": self._username,
258                "verifycode": code,
259                "verifycodetype": "AUTH_EMAIL_CODE",
260            },
261        )
262        if login_response is None:
263            raise RoborockException("Login request response is None")
264        response_code = login_response.get("code")
265        if response_code != 200:
266            _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response)
267            if response_code == 2018:
268                raise RoborockInvalidCode("Invalid code - check your code and try again.")
269            if response_code == 3009:
270                raise RoborockNoUserAgreement("You must accept the user agreement in the Roborock app to continue.")
271            if response_code == 3006:
272                raise RoborockInvalidUserAgreement(
273                    "User agreement must be accepted again - or you are attempting to use the Mi Home app account."
274                )
275            raise RoborockException(f"{login_response.get('msg')} - response code: {response_code}")
276        user_data = login_response.get("data")
277        if not isinstance(user_data, dict):
278            raise RoborockException("Got unexpected data type for user_data")
279        return UserData.from_dict(user_data)

Email/password and email-code login flows.

async def request_code(self) -> None:
42    async def request_code(self) -> None:
43        if not await self._login_limiter.try_acquire_async("login", blocking=True, timeout=1):
44            raise RoborockRateLimit("Reached maximum requests for login. Please try again later.")
45        code_request = await self._iot_request()
46
47        code_response = await code_request.request(
48            "post",
49            "/api/v1/sendEmailCode",
50            params={
51                "username": self._username,
52                "type": "auth",
53            },
54        )
55        if code_response is None:
56            raise RoborockException("Failed to get a response from send email code")
57        response_code = code_response.get("code")
58        if response_code != 200:
59            _LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response)
60            if response_code == 2008:
61                raise RoborockAccountDoesNotExist("Account does not exist - check your login and try again.")
62            elif response_code == 9002:
63                raise RoborockTooFrequentCodeRequests("You have attempted to request too many codes. Try again later")
64            else:
65                raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}")
async def request_code_v4(self) -> None:
 67    async def request_code_v4(self) -> None:
 68        """Request a code using the v4 endpoint."""
 69        if await self.country_code is None or await self.country is None:
 70            _LOGGER.info("No country code or country found, trying old version of request code.")
 71            return await self.request_code()
 72        if not await self._login_limiter.try_acquire_async("login", blocking=True, timeout=1):
 73            raise RoborockRateLimit("Reached maximum requests for login. Please try again later.")
 74        code_request = await self._iot_request(
 75            {
 76                "Content-Type": "application/x-www-form-urlencoded",
 77                "header_clientlang": "en",
 78            }
 79        )
 80
 81        code_response = await code_request.request(
 82            "post",
 83            "/api/v4/email/code/send",
 84            data={"email": self._username, "type": "login", "platform": ""},
 85        )
 86        if code_response is None:
 87            raise RoborockException("Failed to get a response from send email code")
 88        response_code = code_response.get("code")
 89        if response_code != 200:
 90            _LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response)
 91            if response_code == 2008:
 92                raise RoborockAccountDoesNotExist("Account does not exist - check your login and try again.")
 93            elif response_code == 9002:
 94                raise RoborockTooFrequentCodeRequests("You have attempted to request too many codes. Try again later")
 95            elif response_code == 3030 and len(self._base_urls) > 1:
 96                self._base_urls = self._base_urls[1:]
 97                self._iot_login_info = None
 98                return await self.request_code_v4()
 99            else:
100                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:
150    async def code_login_v4(
151        self, code: int | str, country: str | None = None, country_code: int | None = None
152    ) -> UserData:
153        """
154        Login via code authentication.
155        :param code: The code from the email.
156        :param country: The two-character representation of the country, i.e. "US"
157        :param country_code: the country phone number code i.e. 1 for US.
158        """
159        if country is None:
160            country = await self.country
161        if country_code is None:
162            country_code = await self.country_code
163        if country_code is None or country is None:
164            _LOGGER.info("No country code or country found, trying old version of code login.")
165            return await self.code_login(code)
166        x_mercy_ks = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
167        x_mercy_k = await self._sign_key_v3(x_mercy_ks)
168        login_request = await self._iot_request(
169            {
170                "x-mercy-ks": x_mercy_ks,
171                "x-mercy-k": x_mercy_k,
172                "Content-Type": "application/x-www-form-urlencoded",
173                "header_clientlang": "en",
174                "header_appversion": "4.54.02",
175                "header_phonesystem": "iOS",
176                "header_phonemodel": "iPhone16,1",
177            }
178        )
179        login_response = await login_request.request(
180            "post",
181            "/api/v4/auth/email/login/code",
182            data={
183                "country": country,
184                "countryCode": country_code,
185                "email": self._username,
186                "code": code,
187                **await self._get_agreement_version(country),
188            },
189        )
190        if login_response is None:
191            raise RoborockException("Login request response is None")
192        response_code = login_response.get("code")
193        if response_code != 200:
194            _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response)
195            if response_code == 2018:
196                raise RoborockInvalidCode("Invalid code - check your code and try again.")
197            if response_code == 3009:
198                raise RoborockNoUserAgreement("You must accept the user agreement in the Roborock app to continue.")
199            if response_code == 3006:
200                raise RoborockInvalidUserAgreement(
201                    "User agreement must be accepted again - or you are attempting to use the Mi Home app account."
202                )
203            if response_code == 3039:
204                raise RoborockAccountDoesNotExist(
205                    "This account does not exist - please ensure that you selected the right region and email."
206                )
207            raise RoborockException(f"{login_response.get('msg')} - response code: {response_code}")
208        user_data = login_response.get("data")
209        if not isinstance(user_data, dict):
210            raise RoborockException("Got unexpected data type for user_data")
211        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:
213    async def pass_login(self, password: str) -> UserData:
214        if not await self._login_limiter.try_acquire_async("login", blocking=True, timeout=1):
215            raise RoborockRateLimit("Reached maximum requests for login. Please try again later.")
216        login_request = await self._iot_request()
217        login_response = await login_request.request(
218            "post",
219            "/api/v1/login",
220            params={
221                "username": self._username,
222                "password": password,
223                "needtwostepauth": "false",
224            },
225        )
226        if login_response is None:
227            raise RoborockException("Login response is none")
228        if login_response.get("code") != 200:
229            _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response)
230            raise RoborockException(f"{login_response.get('msg')} - response code: {login_response.get('code')}")
231        user_data = login_response.get("data")
232        if not isinstance(user_data, dict):
233            raise RoborockException("Got unexpected data type for user_data")
234        return UserData.from_dict(user_data)
async def pass_login_v3(self, password: str) -> roborock.data.containers.UserData:
236    async def pass_login_v3(self, password: str) -> UserData:
237        """Seemingly it follows the format below, but password is encrypted in some manner.
238        # login_response = await login_request.request(
239        #     "post",
240        #     "/api/v3/auth/email/login",
241        #     params={
242        #         "email": self._username,
243        #         "password": password,
244        #         "twoStep": 1,
245        #         "version": 0
246        #     },
247        # )
248        """
249        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:
251    async def code_login(self, code: int | str) -> UserData:
252        login_request = await self._iot_request()
253        login_response = await login_request.request(
254            "post",
255            "/api/v1/loginWithCode",
256            params={
257                "username": self._username,
258                "verifycode": code,
259                "verifycodetype": "AUTH_EMAIL_CODE",
260            },
261        )
262        if login_response is None:
263            raise RoborockException("Login request response is None")
264        response_code = login_response.get("code")
265        if response_code != 200:
266            _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response)
267            if response_code == 2018:
268                raise RoborockInvalidCode("Invalid code - check your code and try again.")
269            if response_code == 3009:
270                raise RoborockNoUserAgreement("You must accept the user agreement in the Roborock app to continue.")
271            if response_code == 3006:
272                raise RoborockInvalidUserAgreement(
273                    "User agreement must be accepted again - or you are attempting to use the Mi Home app account."
274                )
275            raise RoborockException(f"{login_response.get('msg')} - response code: {response_code}")
276        user_data = login_response.get("data")
277        if not isinstance(user_data, dict):
278            raise RoborockException("Got unexpected data type for user_data")
279        return UserData.from_dict(user_data)
class DevicesApi(roborock.web_api.RoborockApiBase):
14class DevicesApi(RoborockApiBase):
15    """Adding devices to an account and editing per-device cloud settings."""
16
17    async def nc_prepare(self, user_data: UserData, timezone: str) -> dict:
18        """This gets a few critical parameters for adding a device to your account."""
19        if (
20            user_data.rriot is None
21            or user_data.rriot.r is None
22            or user_data.rriot.u is None
23            or user_data.rriot.r.a is None
24        ):
25            raise RoborockException("Your userdata is missing critical attributes.")
26        base_url = user_data.rriot.r.a
27        prepare_request = PreparedRequest(base_url, self.session)
28        hid = await self._get_home_id(user_data)
29
30        data = FormData()
31        data.add_field("hid", hid)
32        data.add_field("tzid", timezone)
33
34        prepare_response = await prepare_request.request(
35            "post",
36            "/nc/prepare",
37            headers={
38                "Authorization": _get_hawk_authentication(
39                    user_data.rriot, "/nc/prepare", {"hid": hid, "tzid": timezone}
40                ),
41            },
42            data=data,
43        )
44
45        if prepare_response is None:
46            raise RoborockException("prepare_response is None")
47        if not prepare_response.get("success"):
48            raise RoborockException(f"{prepare_response.get('msg')} - response code: {prepare_response.get('code')}")
49
50        return prepare_response["result"]
51
52    async def add_device(self, user_data: UserData, s: str, t: str) -> dict:
53        """This will add a new device to your account
54        it is recommended to only use this during a pairing cycle with a device.
55        Please see here: https://github.com/Python-roborock/Roborockmitmproxy/blob/main/handshake_protocol.md
56        """
57        if (
58            user_data.rriot is None
59            or user_data.rriot.r is None
60            or user_data.rriot.u is None
61            or user_data.rriot.r.a is None
62        ):
63            raise RoborockException("Your userdata is missing critical attributes.")
64        base_url = user_data.rriot.r.a
65        add_device_request = PreparedRequest(base_url, self.session)
66
67        add_device_response = await add_device_request.request(
68            "GET",
69            "/user/devices/newadd",
70            headers={
71                "Authorization": _get_hawk_authentication(
72                    user_data.rriot, "/user/devices/newadd", params={"s": s, "t": t}
73                ),
74            },
75            params={"s": s, "t": t},
76        )
77
78        if add_device_response is None:
79            raise RoborockException("add_device is None")
80        if not add_device_response.get("success"):
81            raise RoborockException(
82                f"{add_device_response.get('msg')} - response code: {add_device_response.get('code')}"
83            )
84
85        return add_device_response["result"]

Adding devices to an account and editing per-device cloud settings.

async def nc_prepare( self, user_data: roborock.data.containers.UserData, timezone: str) -> dict:
17    async def nc_prepare(self, user_data: UserData, timezone: str) -> dict:
18        """This gets a few critical parameters for adding a device to your account."""
19        if (
20            user_data.rriot is None
21            or user_data.rriot.r is None
22            or user_data.rriot.u is None
23            or user_data.rriot.r.a is None
24        ):
25            raise RoborockException("Your userdata is missing critical attributes.")
26        base_url = user_data.rriot.r.a
27        prepare_request = PreparedRequest(base_url, self.session)
28        hid = await self._get_home_id(user_data)
29
30        data = FormData()
31        data.add_field("hid", hid)
32        data.add_field("tzid", timezone)
33
34        prepare_response = await prepare_request.request(
35            "post",
36            "/nc/prepare",
37            headers={
38                "Authorization": _get_hawk_authentication(
39                    user_data.rriot, "/nc/prepare", {"hid": hid, "tzid": timezone}
40                ),
41            },
42            data=data,
43        )
44
45        if prepare_response is None:
46            raise RoborockException("prepare_response is None")
47        if not prepare_response.get("success"):
48            raise RoborockException(f"{prepare_response.get('msg')} - response code: {prepare_response.get('code')}")
49
50        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:
52    async def add_device(self, user_data: UserData, s: str, t: str) -> dict:
53        """This will add a new device to your account
54        it is recommended to only use this during a pairing cycle with a device.
55        Please see here: https://github.com/Python-roborock/Roborockmitmproxy/blob/main/handshake_protocol.md
56        """
57        if (
58            user_data.rriot is None
59            or user_data.rriot.r is None
60            or user_data.rriot.u is None
61            or user_data.rriot.r.a is None
62        ):
63            raise RoborockException("Your userdata is missing critical attributes.")
64        base_url = user_data.rriot.r.a
65        add_device_request = PreparedRequest(base_url, self.session)
66
67        add_device_response = await add_device_request.request(
68            "GET",
69            "/user/devices/newadd",
70            headers={
71                "Authorization": _get_hawk_authentication(
72                    user_data.rriot, "/user/devices/newadd", params={"s": s, "t": t}
73                ),
74            },
75            params={"s": s, "t": t},
76        )
77
78        if add_device_response is None:
79            raise RoborockException("add_device is None")
80        if not add_device_response.get("success"):
81            raise RoborockException(
82                f"{add_device_response.get('msg')} - response code: {add_device_response.get('code')}"
83            )
84
85        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

class HomesApi(roborock.web_api.RoborockApiBase):
12class HomesApi(RoborockApiBase):
13    """Home data (devices, products, rooms) lookups."""
14
15    _HOME_DATA_RATES = [
16        Rate(1, Duration.SECOND),
17        Rate(3, Duration.MINUTE),
18        Rate(5, Duration.HOUR),
19        Rate(40, Duration.DAY),
20    ]
21    _home_data_limiter = Limiter(_HOME_DATA_RATES)
22
23    async def _get_home_data_version(self, user_data: UserData, path_prefix: str) -> HomeData:
24        """Fetch home data from ``{path_prefix}/user/homes/{home_id}`` with rate limiting."""
25        if not self._home_data_limiter.try_acquire("home_data", blocking=False):
26            raise RoborockRateLimit("Reached maximum requests for home data. Please try again later.")
27        if user_data.rriot is None:
28            raise RoborockException("rriot is none")
29        home_id = await self._get_home_id(user_data)
30        path = f"{path_prefix}/user/homes/{home_id}"
31        home_request = self._rriot_request(user_data, path)
32        home_response = await home_request.request("get", path)
33        if not home_response.get("success"):
34            raise RoborockException(home_response)
35        home_data = home_response.get("result")
36        if isinstance(home_data, dict):
37            return HomeData.from_dict(home_data)
38        raise RoborockException(f"home_response result was an unexpected type: {home_data}")
39
40    async def get_home_data(self, user_data: UserData) -> HomeData:
41        return await self._get_home_data_version(user_data, "")
42
43    async def get_home_data_v2(self, user_data: UserData) -> HomeData:
44        """This is the same as get_home_data, but uses a different endpoint and includes non-robotic vacuums."""
45        return await self._get_home_data_version(user_data, "/v2")
46
47    async def get_home_data_v3(self, user_data: UserData) -> HomeData:
48        """This is the same as get_home_data, but uses a different endpoint and includes non-robotic vacuums."""
49        return await self._get_home_data_version(user_data, "/v3")
50
51    async def get_rooms(self, user_data: UserData, home_id: int | None = None) -> list[HomeDataRoom]:
52        if user_data.rriot is None:
53            raise RoborockException("rriot is none")
54        if home_id is None:
55            home_id = await self._get_home_id(user_data)
56        path = f"/user/homes/{home_id}/rooms"
57        room_request = self._rriot_request(user_data, path)
58        room_response = await room_request.request("get", path)
59        if not room_response.get("success"):
60            raise RoborockException(room_response)
61        rooms = room_response.get("result")
62        if isinstance(rooms, list):
63            return [HomeDataRoom.from_dict(room) for room in rooms]
64        raise RoborockException("home_response result was an unexpected type")
65
66    async def get_shared_device_rooms(self, user_data: UserData, device_id: str) -> list[HomeDataRoom]:
67        """Fetch room names for a shared (received) device."""
68        path = f"/user/deviceshare/query/{device_id}/rooms"
69        room_request = self._rriot_request(user_data, path)
70        room_response = await room_request.request("get", path)
71        if not room_response.get("success"):
72            raise RoborockException(room_response)
73        rooms = room_response.get("result")
74        if isinstance(rooms, list):
75            output_list = []
76            for room in rooms:
77                normalized_room = room
78                if isinstance(room, dict) and "id" not in room and "roomId" in room:
79                    normalized_room = {**room, "id": room["roomId"]}
80                output_list.append(HomeDataRoom.from_dict(normalized_room))
81            return output_list
82        raise RoborockException("get_shared_device_rooms result was an unexpected type")

Home data (devices, products, rooms) lookups.

async def get_home_data( self, user_data: roborock.data.containers.UserData) -> roborock.data.containers.HomeData:
40    async def get_home_data(self, user_data: UserData) -> HomeData:
41        return await self._get_home_data_version(user_data, "")
async def get_home_data_v2( self, user_data: roborock.data.containers.UserData) -> roborock.data.containers.HomeData:
43    async def get_home_data_v2(self, user_data: UserData) -> HomeData:
44        """This is the same as get_home_data, but uses a different endpoint and includes non-robotic vacuums."""
45        return await self._get_home_data_version(user_data, "/v2")

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:
47    async def get_home_data_v3(self, user_data: UserData) -> HomeData:
48        """This is the same as get_home_data, but uses a different endpoint and includes non-robotic vacuums."""
49        return await self._get_home_data_version(user_data, "/v3")

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]:
51    async def get_rooms(self, user_data: UserData, home_id: int | None = None) -> list[HomeDataRoom]:
52        if user_data.rriot is None:
53            raise RoborockException("rriot is none")
54        if home_id is None:
55            home_id = await self._get_home_id(user_data)
56        path = f"/user/homes/{home_id}/rooms"
57        room_request = self._rriot_request(user_data, path)
58        room_response = await room_request.request("get", path)
59        if not room_response.get("success"):
60            raise RoborockException(room_response)
61        rooms = room_response.get("result")
62        if isinstance(rooms, list):
63            return [HomeDataRoom.from_dict(room) for room in rooms]
64        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]:
66    async def get_shared_device_rooms(self, user_data: UserData, device_id: str) -> list[HomeDataRoom]:
67        """Fetch room names for a shared (received) device."""
68        path = f"/user/deviceshare/query/{device_id}/rooms"
69        room_request = self._rriot_request(user_data, path)
70        room_response = await room_request.request("get", path)
71        if not room_response.get("success"):
72            raise RoborockException(room_response)
73        rooms = room_response.get("result")
74        if isinstance(rooms, list):
75            output_list = []
76            for room in rooms:
77                normalized_room = room
78                if isinstance(room, dict) and "id" not in room and "roomId" in room:
79                    normalized_room = {**room, "id": room["roomId"]}
80                output_list.append(HomeDataRoom.from_dict(normalized_room))
81            return output_list
82        raise RoborockException("get_shared_device_rooms result was an unexpected type")

Fetch room names for a shared (received) device.

@dataclass
class IotLoginInfo:
40@dataclass
41class IotLoginInfo:
42    """Information about the login to the iot server."""
43
44    base_url: str
45    country_code: str
46    country: str

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 JobsApi(roborock.web_api.RoborockApiBase):
11class JobsApi(RoborockApiBase):
12    """Cloud-side cleaning schedules and one-time jobs."""
13
14    async def get_schedules(self, user_data: UserData, device_id: str) -> list[HomeDataSchedule]:
15        path = f"/user/devices/{device_id}/jobs"
16        schedules_request = self._rriot_request(user_data, path)
17        schedules_response = await schedules_request.request("get", path)
18        if not schedules_response.get("success"):
19            raise RoborockException(schedules_response)
20        schedules = schedules_response.get("result")
21        if isinstance(schedules, list):
22            return [HomeDataSchedule.from_dict(schedule) for schedule in schedules]
23        raise RoborockException(f"schedule_response result was an unexpected type: {schedules}")
24
25    async def create_job(self, user_data: UserData, device_id: str, job: dict) -> dict:
26        """Create a /jobs entry (schedule or one-time room clean) on a B01 device.
27
28        Body-bearing writes must sign the request body in the Hawk payload slot and send those same
29        compact bytes via ``data=``; ``json=`` would re-serialize with spaces and break the MAC.
30        """
31        path = f"/user/devices/{device_id}/jobs"
32        job_request = self._rriot_request(user_data, path, body=job, extra_headers={"Content-Type": "application/json"})
33        response = await job_request.request("post", path, data=_compact_json(job).encode())
34        if not response.get("success"):
35            raise RoborockException(response)
36        return response
37
38    async def update_job(self, user_data: UserData, device_id: str, job: HomeDataSchedule) -> None:
39        """Replace an existing cloud job with the full supplied schedule.
40
41        Pass a schedule returned by :meth:`get_schedules` with the desired changes.
42        The job ID identifies the URL; all other fields are sent as the replacement body.
43        """
44        path = f"/user/devices/{device_id}/jobs/{job.id}"
45        body = job.as_dict(exclude={"id"})
46        job_request = self._rriot_request(
47            user_data, path, body=body, extra_headers={"Content-Type": "application/json"}
48        )
49        response = await job_request.request("put", path, data=_compact_json(body).encode())
50        if not response or not response.get("success"):
51            raise RoborockException(response)

Cloud-side cleaning schedules and one-time jobs.

async def get_schedules( self, user_data: roborock.data.containers.UserData, device_id: str) -> list[roborock.data.containers.HomeDataSchedule]:
14    async def get_schedules(self, user_data: UserData, device_id: str) -> list[HomeDataSchedule]:
15        path = f"/user/devices/{device_id}/jobs"
16        schedules_request = self._rriot_request(user_data, path)
17        schedules_response = await schedules_request.request("get", path)
18        if not schedules_response.get("success"):
19            raise RoborockException(schedules_response)
20        schedules = schedules_response.get("result")
21        if isinstance(schedules, list):
22            return [HomeDataSchedule.from_dict(schedule) for schedule in schedules]
23        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:
25    async def create_job(self, user_data: UserData, device_id: str, job: dict) -> dict:
26        """Create a /jobs entry (schedule or one-time room clean) on a B01 device.
27
28        Body-bearing writes must sign the request body in the Hawk payload slot and send those same
29        compact bytes via ``data=``; ``json=`` would re-serialize with spaces and break the MAC.
30        """
31        path = f"/user/devices/{device_id}/jobs"
32        job_request = self._rriot_request(user_data, path, body=job, extra_headers={"Content-Type": "application/json"})
33        response = await job_request.request("post", path, data=_compact_json(job).encode())
34        if not response.get("success"):
35            raise RoborockException(response)
36        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 update_job( self, user_data: roborock.data.containers.UserData, device_id: str, job: roborock.data.containers.HomeDataSchedule) -> None:
38    async def update_job(self, user_data: UserData, device_id: str, job: HomeDataSchedule) -> None:
39        """Replace an existing cloud job with the full supplied schedule.
40
41        Pass a schedule returned by :meth:`get_schedules` with the desired changes.
42        The job ID identifies the URL; all other fields are sent as the replacement body.
43        """
44        path = f"/user/devices/{device_id}/jobs/{job.id}"
45        body = job.as_dict(exclude={"id"})
46        job_request = self._rriot_request(
47            user_data, path, body=body, extra_headers={"Content-Type": "application/json"}
48        )
49        response = await job_request.request("put", path, data=_compact_json(body).encode())
50        if not response or not response.get("success"):
51            raise RoborockException(response)

Replace an existing cloud job with the full supplied schedule.

Pass a schedule returned by get_schedules() with the desired changes. The job ID identifies the URL; all other fields are sent as the replacement body.

class OtaApi(roborock.web_api.RoborockApiBase):
10class OtaApi(RoborockApiBase):
11    """Firmware update discovery and control."""
12
13    async def get_firmware_info(self, user_data: UserData, device_id: str) -> FirmwareInfo:
14        """Get firmware/OTA info for a device (latest version + updatable flag)."""
15        path = f"/ota/firmware/{device_id}/updatev2"
16        params = {"lang": "en"}
17        firmware_request = self._rriot_request(user_data, path, params=params)
18        firmware_response = await firmware_request.request("get", path, params=params)
19        if not firmware_response or not firmware_response.get("success"):
20            raise RoborockException(firmware_response)
21        return FirmwareInfo.from_dict(firmware_response.get("result") or {})
22
23    async def start_firmware_update(self, user_data: UserData, device_id: str) -> None:
24        """Trigger the (irreversible) firmware update for a device.
25
26        The device downloads and flashes the latest firmware reported by
27        :meth:`get_firmware_info`.
28        """
29        path = f"/ota/device/{device_id}/upgrade"
30        upgrade_request = self._rriot_request(user_data, path)
31        upgrade_response = await upgrade_request.request("post", path)
32        if not upgrade_response or not upgrade_response.get("success"):
33            raise RoborockException(upgrade_response)
34
35    async def set_silent_ota(self, user_data: UserData, device_id: str, enabled: bool) -> None:
36        """Enable/disable automatic (silent) firmware updates for a device.
37
38        The current value is reported as ``silent_ota_switch`` on the home-data device.
39        """
40        path = f"/user/devices/{device_id}"
41        formdata = {"silentOtaSwitch": "true" if enabled else "false"}
42        silent_ota_request = self._rriot_request(user_data, path, formdata=formdata)
43        silent_ota_response = await silent_ota_request.request("put", path, data=formdata)
44        if not silent_ota_response or not silent_ota_response.get("success"):
45            raise RoborockException(silent_ota_response)

Firmware update discovery and control.

async def get_firmware_info( self, user_data: roborock.data.containers.UserData, device_id: str) -> roborock.data.containers.FirmwareInfo:
13    async def get_firmware_info(self, user_data: UserData, device_id: str) -> FirmwareInfo:
14        """Get firmware/OTA info for a device (latest version + updatable flag)."""
15        path = f"/ota/firmware/{device_id}/updatev2"
16        params = {"lang": "en"}
17        firmware_request = self._rriot_request(user_data, path, params=params)
18        firmware_response = await firmware_request.request("get", path, params=params)
19        if not firmware_response or not firmware_response.get("success"):
20            raise RoborockException(firmware_response)
21        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:
23    async def start_firmware_update(self, user_data: UserData, device_id: str) -> None:
24        """Trigger the (irreversible) firmware update for a device.
25
26        The device downloads and flashes the latest firmware reported by
27        :meth:`get_firmware_info`.
28        """
29        path = f"/ota/device/{device_id}/upgrade"
30        upgrade_request = self._rriot_request(user_data, path)
31        upgrade_response = await upgrade_request.request("post", path)
32        if not upgrade_response or not upgrade_response.get("success"):
33            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:
35    async def set_silent_ota(self, user_data: UserData, device_id: str, enabled: bool) -> None:
36        """Enable/disable automatic (silent) firmware updates for a device.
37
38        The current value is reported as ``silent_ota_switch`` on the home-data device.
39        """
40        path = f"/user/devices/{device_id}"
41        formdata = {"silentOtaSwitch": "true" if enabled else "false"}
42        silent_ota_request = self._rriot_request(user_data, path, formdata=formdata)
43        silent_ota_response = await silent_ota_request.request("put", path, data=formdata)
44        if not silent_ota_response or not silent_ota_response.get("success"):
45            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.

class PreparedRequest:
14class PreparedRequest:
15    def __init__(
16        self, base_url: str, session: aiohttp.ClientSession | None = None, base_headers: dict | None = None
17    ) -> None:
18        self.base_url = base_url
19        self.base_headers = base_headers or {}
20        self.session = session
21
22    async def request(self, method: str, url: str, params=None, data=None, headers=None, json=None) -> dict:
23        _url = "/".join(s.strip("/") for s in [self.base_url, url])
24        _headers = {**self.base_headers, **(headers or {})}
25        close_session = self.session is None
26        session = self.session if self.session is not None else aiohttp.ClientSession()
27        try:
28            async with session.request(method, _url, params=params, data=data, headers=_headers, json=json) as resp:
29                return await resp.json()
30        except ContentTypeError as err:
31            """If we get an error, lets log everything for debugging."""
32            try:
33                resp_json = await resp.json(content_type=None)
34                _LOGGER.info("Resp: %s", resp_json)
35            except ContentTypeError as err_2:
36                _LOGGER.info(err_2)
37            resp_raw = await resp.read()
38            _LOGGER.info("Resp raw: %s", resp_raw)
39            # Still raise the err so that it's clear it failed.
40            raise err
41        except (aiohttp.ClientError, TimeoutError, OSError) as err:
42            raise RoborockException(f"Network error contacting {_url}: {err}") from err
43        finally:
44            if close_session:
45                await session.close()
PreparedRequest( base_url: str, session: aiohttp.client.ClientSession | None = None, base_headers: dict | None = None)
15    def __init__(
16        self, base_url: str, session: aiohttp.ClientSession | None = None, base_headers: dict | None = None
17    ) -> None:
18        self.base_url = base_url
19        self.base_headers = base_headers or {}
20        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:
22    async def request(self, method: str, url: str, params=None, data=None, headers=None, json=None) -> dict:
23        _url = "/".join(s.strip("/") for s in [self.base_url, url])
24        _headers = {**self.base_headers, **(headers or {})}
25        close_session = self.session is None
26        session = self.session if self.session is not None else aiohttp.ClientSession()
27        try:
28            async with session.request(method, _url, params=params, data=data, headers=_headers, json=json) as resp:
29                return await resp.json()
30        except ContentTypeError as err:
31            """If we get an error, lets log everything for debugging."""
32            try:
33                resp_json = await resp.json(content_type=None)
34                _LOGGER.info("Resp: %s", resp_json)
35            except ContentTypeError as err_2:
36                _LOGGER.info(err_2)
37            resp_raw = await resp.read()
38            _LOGGER.info("Resp raw: %s", resp_raw)
39            # Still raise the err so that it's clear it failed.
40            raise err
41        except (aiohttp.ClientError, TimeoutError, OSError) as err:
42            raise RoborockException(f"Network error contacting {_url}: {err}") from err
43        finally:
44            if close_session:
45                await session.close()
class ProductsApi(roborock.web_api.RoborockApiBase):
10class ProductsApi(RoborockApiBase):
11    """Product schemas and device plugin bundles."""
12
13    async def get_products(self, user_data: UserData) -> ProductResponse:
14        """Gets all products and their schemas, good for determining status codes and model numbers."""
15        product_request = await self._iot_request()
16        product_response = await product_request.request(
17            "get",
18            "/api/v4/product",
19            headers={"Authorization": user_data.token},
20        )
21        if product_response is None:
22            raise RoborockException("home_id_response is None")
23        if product_response.get("code") != 200:
24            raise RoborockException(f"{product_response.get('msg')} - response code: {product_response.get('code')}")
25        result = product_response.get("data")
26        if isinstance(result, dict):
27            return ProductResponse.from_dict(result)
28        raise RoborockException("product result was an unexpected type")
29
30    async def download_code(self, user_data: UserData, product_id: int):
31        product_request = await self._iot_request()
32        request = {"apilevel": 99999, "productids": [product_id], "type": 2}
33        response = await product_request.request(
34            "post",
35            "/api/v1/appplugin",
36            json=request,
37            headers={"Authorization": user_data.token, "Content-Type": "application/json"},
38        )
39        return response["data"][0]["url"]
40
41    async def download_category_code(self, user_data: UserData):
42        product_request = await self._iot_request()
43        response = await product_request.request(
44            "get",
45            "api/v1/plugins?apiLevel=99999&type=2",
46            headers={
47                "Authorization": user_data.token,
48            },
49        )
50        return {r["category"]: r["url"] for r in response["data"]["categoryPluginList"]}

Product schemas and device plugin bundles.

async def get_products( self, user_data: roborock.data.containers.UserData) -> roborock.data.containers.ProductResponse:
13    async def get_products(self, user_data: UserData) -> ProductResponse:
14        """Gets all products and their schemas, good for determining status codes and model numbers."""
15        product_request = await self._iot_request()
16        product_response = await product_request.request(
17            "get",
18            "/api/v4/product",
19            headers={"Authorization": user_data.token},
20        )
21        if product_response is None:
22            raise RoborockException("home_id_response is None")
23        if product_response.get("code") != 200:
24            raise RoborockException(f"{product_response.get('msg')} - response code: {product_response.get('code')}")
25        result = product_response.get("data")
26        if isinstance(result, dict):
27            return ProductResponse.from_dict(result)
28        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):
30    async def download_code(self, user_data: UserData, product_id: int):
31        product_request = await self._iot_request()
32        request = {"apilevel": 99999, "productids": [product_id], "type": 2}
33        response = await product_request.request(
34            "post",
35            "/api/v1/appplugin",
36            json=request,
37            headers={"Authorization": user_data.token, "Content-Type": "application/json"},
38        )
39        return response["data"][0]["url"]
async def download_category_code(self, user_data: roborock.data.containers.UserData):
41    async def download_category_code(self, user_data: UserData):
42        product_request = await self._iot_request()
43        response = await product_request.request(
44            "get",
45            "api/v1/plugins?apiLevel=99999&type=2",
46            headers={
47                "Authorization": user_data.token,
48            },
49        )
50        return {r["category"]: r["url"] for r in response["data"]["categoryPluginList"]}
class RoborockApiBase:
 49class RoborockApiBase:
 50    """State and helpers shared by all web API resource mixins."""
 51
 52    def __init__(
 53        self, username: str, base_url: str | None = None, session: aiohttp.ClientSession | None = None
 54    ) -> None:
 55        """Sample API Client."""
 56        self._username = username
 57        self._base_url = base_url
 58        self._device_identifier = secrets.token_urlsafe(16)
 59        self.session = session
 60        self._iot_login_info: IotLoginInfo | None = None
 61        self._base_urls = BASE_URLS if base_url is None else [base_url]
 62
 63    async def _get_iot_login_info(self) -> IotLoginInfo:
 64        if self._iot_login_info is None:
 65            for iot_url in self._base_urls:
 66                url_request = PreparedRequest(iot_url, self.session)
 67                response = await url_request.request(
 68                    "post",
 69                    "/api/v1/getUrlByEmail",
 70                    params={"email": self._username, "needtwostepauth": "false"},
 71                )
 72                if response is None:
 73                    continue
 74                response_code = response.get("code")
 75                if response_code != 200:
 76                    if response_code == 2003:
 77                        raise RoborockInvalidEmail("Your email was incorrectly formatted.")
 78                    elif response_code == 1001:
 79                        raise RoborockMissingParameters(
 80                            "You are missing parameters for this request, are you sure you entered your username?"
 81                        )
 82                    else:
 83                        raise RoborockException(f"{response.get('msg')} - response code: {response_code}")
 84                country_code = response["data"]["countrycode"]
 85                country = response["data"]["country"]
 86                if country_code is not None or country is not None:
 87                    self._iot_login_info = IotLoginInfo(
 88                        base_url=response["data"]["url"],
 89                        country=country,
 90                        country_code=country_code,
 91                    )
 92                    _LOGGER.debug("Country determined to be %s and code is %s", country, country_code)
 93                    return self._iot_login_info
 94            raise RoborockNoResponseFromBaseURL(
 95                "No account was found for any base url we tried. Either your email is incorrect or we do not have a"
 96                " record of the roborock server your device is on."
 97            )
 98        return self._iot_login_info
 99
100    @property
101    async def base_url(self):
102        if self._base_url is not None:
103            return self._base_url
104        return (await self._get_iot_login_info()).base_url
105
106    @property
107    async def country(self):
108        return (await self._get_iot_login_info()).country
109
110    @property
111    async def country_code(self):
112        return (await self._get_iot_login_info()).country_code
113
114    def _get_header_client_id(self):
115        md5 = hashlib.md5()
116        md5.update(self._username.encode())
117        md5.update(self._device_identifier.encode())
118        return base64.b64encode(md5.digest()).decode()
119
120    async def _iot_request(self, extra_headers: dict | None = None) -> PreparedRequest:
121        """Build a request against the regional Base IoT server with the standard client headers."""
122        base_url = await self.base_url
123        headers = {"header_clientid": self._get_header_client_id(), **(extra_headers or {})}
124        return PreparedRequest(base_url, self.session, headers)
125
126    def _rriot_request(
127        self,
128        user_data: UserData,
129        path: str,
130        *,
131        formdata: dict | None = None,
132        params: dict | None = None,
133        body: dict | None = None,
134        extra_headers: dict | None = None,
135    ) -> PreparedRequest:
136        """Build a Hawk-authenticated request against the RRIoT server for ``path``.
137
138        The same ``formdata``/``params``/``body`` passed here must be sent on the request so
139        that the signed MAC matches what the server receives.
140        """
141        rriot = user_data.rriot
142        if rriot is None:
143            raise RoborockException("rriot is none")
144        if rriot.r.a is None:
145            raise RoborockException("Missing field 'a' in rriot reference")
146        headers = {
147            "Authorization": _get_hawk_authentication(rriot, path, formdata=formdata, params=params, body=body),
148            **(extra_headers or {}),
149        }
150        return PreparedRequest(rriot.r.a, self.session, headers)
151
152    async def _get_home_id(self, user_data: UserData):
153        home_id_request = await self._iot_request()
154        home_id_response = await home_id_request.request(
155            "get",
156            "/api/v1/getHomeDetail",
157            headers={"Authorization": user_data.token},
158        )
159        if home_id_response is None:
160            raise RoborockException("home_id_response is None")
161        if home_id_response.get("code") != 200:
162            _LOGGER.info("Get Home Id failed with the following context: %s", home_id_response)
163            if home_id_response.get("code") == 2010:
164                raise RoborockInvalidCredentials(
165                    f"Invalid credentials ({home_id_response.get('msg')}) - check your login and try again."
166                )
167            raise RoborockException(f"{home_id_response.get('msg')} - response code: {home_id_response.get('code')}")
168
169        return home_id_response["data"]["rrHomeId"]

State and helpers shared by all web API resource mixins.

RoborockApiBase( username: str, base_url: str | None = None, session: aiohttp.client.ClientSession | None = None)
52    def __init__(
53        self, username: str, base_url: str | None = None, session: aiohttp.ClientSession | None = None
54    ) -> None:
55        """Sample API Client."""
56        self._username = username
57        self._base_url = base_url
58        self._device_identifier = secrets.token_urlsafe(16)
59        self.session = session
60        self._iot_login_info: IotLoginInfo | None = None
61        self._base_urls = BASE_URLS if base_url is None else [base_url]

Sample API Client.

session
base_url
100    @property
101    async def base_url(self):
102        if self._base_url is not None:
103            return self._base_url
104        return (await self._get_iot_login_info()).base_url
country
106    @property
107    async def country(self):
108        return (await self._get_iot_login_info()).country
country_code
110    @property
111    async def country_code(self):
112        return (await self._get_iot_login_info()).country_code
18class RoborockApiClient(AuthApi, HomesApi, DevicesApi, ScenesApi, JobsApi, OtaApi, ProductsApi):
19    """Client for the Roborock cloud REST APIs.
20
21    Each group of endpoints lives in its own mixin (``roborock.web_api.auth``,
22    ``roborock.web_api.homes`` ...); this class simply composes them so callers keep a
23    single entry point.
24    """

Client for the Roborock cloud REST APIs.

Each group of endpoints lives in its own mixin (roborock.web_api.auth, roborock.web_api.homes ...); this class simply composes them so callers keep a single entry point.

class ScenesApi(roborock.web_api.RoborockApiBase):
10class ScenesApi(RoborockApiBase):
11    """Listing and executing app routines."""
12
13    async def get_scenes(self, user_data: UserData, device_id: str) -> list[HomeDataScene]:
14        path = f"/user/scene/device/{str(device_id)}"
15        scenes_request = self._rriot_request(user_data, path)
16        scenes_response = await scenes_request.request("get", path)
17        if not scenes_response.get("success"):
18            raise RoborockException(scenes_response)
19        scenes = scenes_response.get("result")
20        if isinstance(scenes, list):
21            return [HomeDataScene.from_dict(scene) for scene in scenes]
22        raise RoborockException("scene_response result was an unexpected type")
23
24    async def execute_scene(self, user_data: UserData, scene_id: int) -> None:
25        path = f"/user/scene/{str(scene_id)}/execute"
26        execute_scene_request = self._rriot_request(user_data, path)
27        execute_scene_response = await execute_scene_request.request("POST", path)
28        if not execute_scene_response.get("success"):
29            raise RoborockException(execute_scene_response)

Listing and executing app routines.

async def get_scenes( self, user_data: roborock.data.containers.UserData, device_id: str) -> list[roborock.data.containers.HomeDataScene]:
13    async def get_scenes(self, user_data: UserData, device_id: str) -> list[HomeDataScene]:
14        path = f"/user/scene/device/{str(device_id)}"
15        scenes_request = self._rriot_request(user_data, path)
16        scenes_response = await scenes_request.request("get", path)
17        if not scenes_response.get("success"):
18            raise RoborockException(scenes_response)
19        scenes = scenes_response.get("result")
20        if isinstance(scenes, list):
21            return [HomeDataScene.from_dict(scene) for scene in scenes]
22        raise RoborockException("scene_response result was an unexpected type")
async def execute_scene( self, user_data: roborock.data.containers.UserData, scene_id: int) -> None:
24    async def execute_scene(self, user_data: UserData, scene_id: int) -> None:
25        path = f"/user/scene/{str(scene_id)}/execute"
26        execute_scene_request = self._rriot_request(user_data, path)
27        execute_scene_response = await execute_scene_request.request("POST", path)
28        if not execute_scene_response.get("success"):
29            raise RoborockException(execute_scene_response)
class UserWebApiClient:
27class UserWebApiClient:
28    """Wrapper around RoborockApiClient to provide information for a specific user.
29
30    This binds a RoborockApiClient to a specific user context with the
31    provided UserData. This allows for easier access to user-specific data,
32    to avoid needing to pass UserData around and mock out the web API.
33    """
34
35    def __init__(
36        self, web_api: RoborockApiClient, user_data: UserData, unauthorized_hook: Callable[[], None] | None = None
37    ) -> None:
38        """Initialize the wrapper with the API client and user data."""
39        self._web_api = web_api
40        self._user_data = user_data
41        self._unauthorized_hook = unauthorized_hook
42
43    async def get_home_data(self) -> HomeData:
44        """Fetch home data using the API client."""
45        try:
46            return await self._web_api.get_home_data_v3(self._user_data)
47        except RoborockInvalidCredentials:
48            if self._unauthorized_hook:
49                self._unauthorized_hook()
50            raise
51
52    async def get_routines(self, device_id: str) -> list[HomeDataScene]:
53        """Fetch routines (scenes) for a specific device."""
54        try:
55            return await self._web_api.get_scenes(self._user_data, device_id)
56        except RoborockInvalidCredentials:
57            if self._unauthorized_hook:
58                self._unauthorized_hook()
59            raise
60
61    async def get_rooms(self) -> list[HomeDataRoom]:
62        """Fetch rooms using the API client."""
63        try:
64            return await self._web_api.get_rooms(self._user_data)
65        except RoborockInvalidCredentials:
66            if self._unauthorized_hook:
67                self._unauthorized_hook()
68            raise
69
70    async def get_shared_device_rooms(self, device_id: str) -> list[HomeDataRoom]:
71        """Fetch shared-device rooms using the API client."""
72        return await self._web_api.get_shared_device_rooms(self._user_data, device_id)
73
74    async def execute_routine(self, scene_id: int) -> None:
75        """Execute a specific routine (scene) by its ID."""
76        try:
77            await self._web_api.execute_scene(self._user_data, scene_id)
78        except RoborockInvalidCredentials:
79            if self._unauthorized_hook:
80                self._unauthorized_hook()
81            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)
35    def __init__(
36        self, web_api: RoborockApiClient, user_data: UserData, unauthorized_hook: Callable[[], None] | None = None
37    ) -> None:
38        """Initialize the wrapper with the API client and user data."""
39        self._web_api = web_api
40        self._user_data = user_data
41        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:
43    async def get_home_data(self) -> HomeData:
44        """Fetch home data using the API client."""
45        try:
46            return await self._web_api.get_home_data_v3(self._user_data)
47        except RoborockInvalidCredentials:
48            if self._unauthorized_hook:
49                self._unauthorized_hook()
50            raise

Fetch home data using the API client.

async def get_routines(self, device_id: str) -> list[roborock.data.containers.HomeDataScene]:
52    async def get_routines(self, device_id: str) -> list[HomeDataScene]:
53        """Fetch routines (scenes) for a specific device."""
54        try:
55            return await self._web_api.get_scenes(self._user_data, device_id)
56        except RoborockInvalidCredentials:
57            if self._unauthorized_hook:
58                self._unauthorized_hook()
59            raise

Fetch routines (scenes) for a specific device.

async def get_rooms(self) -> list[roborock.data.containers.HomeDataRoom]:
61    async def get_rooms(self) -> list[HomeDataRoom]:
62        """Fetch rooms using the API client."""
63        try:
64            return await self._web_api.get_rooms(self._user_data)
65        except RoborockInvalidCredentials:
66            if self._unauthorized_hook:
67                self._unauthorized_hook()
68            raise

Fetch rooms using the API client.

async def get_shared_device_rooms(self, device_id: str) -> list[roborock.data.containers.HomeDataRoom]:
70    async def get_shared_device_rooms(self, device_id: str) -> list[HomeDataRoom]:
71        """Fetch shared-device rooms using the API client."""
72        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:
74    async def execute_routine(self, scene_id: int) -> None:
75        """Execute a specific routine (scene) by its ID."""
76        try:
77            await self._web_api.execute_scene(self._user_data, scene_id)
78        except RoborockInvalidCredentials:
79            if self._unauthorized_hook:
80                self._unauthorized_hook()
81            raise

Execute a specific routine (scene) by its ID.