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]
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.
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')}")
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.
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.
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)
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
},
)
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)
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.
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.
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
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.
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.
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.
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")
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.
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.
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}")
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.
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.
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.
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).
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().
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.
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()
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()
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.
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.
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"]
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"]}
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.
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.
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.
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.
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")
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)
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.
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.
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.
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.
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.
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.