roborock-cli 0.1.1__py3-none-any.whl

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