botslab360 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {botslab360-0.2.0/src/botslab360.egg-info → botslab360-0.3.0}/PKG-INFO +29 -7
- {botslab360-0.2.0 → botslab360-0.3.0}/README.md +28 -6
- {botslab360-0.2.0 → botslab360-0.3.0}/pyproject.toml +1 -1
- {botslab360-0.2.0 → botslab360-0.3.0}/src/botslab360/__init__.py +2 -0
- {botslab360-0.2.0 → botslab360-0.3.0}/src/botslab360/client.py +248 -2
- {botslab360-0.2.0 → botslab360-0.3.0}/src/botslab360/models.py +12 -0
- botslab360-0.3.0/src/botslab360/protocol.py +715 -0
- botslab360-0.3.0/src/botslab360/rooms.py +303 -0
- {botslab360-0.2.0 → botslab360-0.3.0/src/botslab360.egg-info}/PKG-INFO +29 -7
- {botslab360-0.2.0 → botslab360-0.3.0}/src/botslab360.egg-info/SOURCES.txt +3 -0
- botslab360-0.3.0/tests/test_protocol.py +960 -0
- botslab360-0.3.0/tests/test_public_room_api_diagnostic.py +257 -0
- {botslab360-0.2.0 → botslab360-0.3.0}/tests/test_quc.py +10 -0
- botslab360-0.3.0/tests/test_room_diagnostic.py +1265 -0
- botslab360-0.3.0/tests/test_rooms.py +373 -0
- {botslab360-0.2.0 → botslab360-0.3.0}/tests/test_status.py +15 -2
- botslab360-0.2.0/src/botslab360/protocol.py +0 -311
- botslab360-0.2.0/tests/test_protocol.py +0 -180
- botslab360-0.2.0/tests/test_room_diagnostic.py +0 -132
- {botslab360-0.2.0 → botslab360-0.3.0}/ATTRIBUTION.md +0 -0
- {botslab360-0.2.0 → botslab360-0.3.0}/LICENSE +0 -0
- {botslab360-0.2.0 → botslab360-0.3.0}/setup.cfg +0 -0
- {botslab360-0.2.0 → botslab360-0.3.0}/src/botslab360/auth.py +0 -0
- {botslab360-0.2.0 → botslab360-0.3.0}/src/botslab360/commands.py +0 -0
- {botslab360-0.2.0 → botslab360-0.3.0}/src/botslab360/exceptions.py +0 -0
- {botslab360-0.2.0 → botslab360-0.3.0}/src/botslab360/quc.py +0 -0
- {botslab360-0.2.0 → botslab360-0.3.0}/src/botslab360.egg-info/dependency_links.txt +0 -0
- {botslab360-0.2.0 → botslab360-0.3.0}/src/botslab360.egg-info/requires.txt +0 -0
- {botslab360-0.2.0 → botslab360-0.3.0}/src/botslab360.egg-info/top_level.txt +0 -0
- {botslab360-0.2.0 → botslab360-0.3.0}/tests/test_android_360_diagnostic.py +0 -0
- {botslab360-0.2.0 → botslab360-0.3.0}/tests/test_auth.py +0 -0
- {botslab360-0.2.0 → botslab360-0.3.0}/tests/test_commands.py +0 -0
- {botslab360-0.2.0 → botslab360-0.3.0}/tests/test_compare_android_quc.py +0 -0
- {botslab360-0.2.0 → botslab360-0.3.0}/tests/test_credentials_auth_example.py +0 -0
- {botslab360-0.2.0 → botslab360-0.3.0}/tests/test_devices.py +0 -0
- {botslab360-0.2.0 → botslab360-0.3.0}/tests/test_public_robot360_auth_diagnostic.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: botslab360
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Async Python client for Botslab/360 robot vacuums
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Source, https://github.com/placix/python-botslab360
|
|
@@ -26,7 +26,7 @@ This project provides an unofficial Python interface for selected 360 robot vacu
|
|
|
26
26
|
|
|
27
27
|
> [!WARNING]
|
|
28
28
|
> This project is unofficial and not affiliated with Botslab, Qihoo 360 or 360 Smart Home.
|
|
29
|
-
> Version `0.
|
|
29
|
+
> Version `0.3.0` should be considered experimental.
|
|
30
30
|
|
|
31
31
|
## Features
|
|
32
32
|
|
|
@@ -41,15 +41,17 @@ Currently implemented:
|
|
|
41
41
|
- Robot status retrieval
|
|
42
42
|
- TCP / push protocol communication
|
|
43
43
|
- AES decryption of push messages
|
|
44
|
+
- Current room discovery from the robot map
|
|
45
|
+
- Single-room and multi-room cleaning
|
|
44
46
|
- Start cleaning
|
|
45
47
|
- Pause cleaning
|
|
46
48
|
- Resume cleaning
|
|
47
49
|
- Return to dock
|
|
48
50
|
- Locate robot
|
|
49
51
|
|
|
50
|
-
|
|
52
|
+
Device control and room/map support have been verified with:
|
|
51
53
|
|
|
52
|
-
- 360 S9-P
|
|
54
|
+
- 360 S9-P using the 360Robot backend
|
|
53
55
|
|
|
54
56
|
Other models may work but have not yet been verified.
|
|
55
57
|
|
|
@@ -161,7 +163,7 @@ async def main() -> None:
|
|
|
161
163
|
print(device.name)
|
|
162
164
|
print(device.model)
|
|
163
165
|
|
|
164
|
-
status = await client.get_status(device)
|
|
166
|
+
status = await client.get_status(device.id)
|
|
165
167
|
|
|
166
168
|
print(f"Battery: {status.battery}%")
|
|
167
169
|
print(f"State: {status.state}")
|
|
@@ -189,6 +191,27 @@ async with Botslab360Client(q, t) as client:
|
|
|
189
191
|
await client.locate(robot)
|
|
190
192
|
```
|
|
191
193
|
|
|
194
|
+
### Room cleaning
|
|
195
|
+
|
|
196
|
+
Room cleaning always fetches the current map before validating and sending the
|
|
197
|
+
selection. Room IDs are device- and map-specific; do not hard-code IDs without
|
|
198
|
+
first reading the current room list.
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
rooms = await client.get_rooms(robot)
|
|
202
|
+
|
|
203
|
+
for room in rooms:
|
|
204
|
+
print(room.id, room.name)
|
|
205
|
+
|
|
206
|
+
await client.clean_rooms(robot, [1])
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Pass multiple IDs to clean several rooms in one request:
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
await client.clean_rooms(robot, [1, 6])
|
|
213
|
+
```
|
|
214
|
+
|
|
192
215
|
## Status information
|
|
193
216
|
|
|
194
217
|
Depending on the robot model, status information may include:
|
|
@@ -294,9 +317,8 @@ Planned future work may include:
|
|
|
294
317
|
- Additional robot models
|
|
295
318
|
- Persistent push connection
|
|
296
319
|
- Fan speed control
|
|
297
|
-
- Room cleaning
|
|
298
320
|
- Zone cleaning
|
|
299
|
-
-
|
|
321
|
+
- Additional map features
|
|
300
322
|
|
|
301
323
|
## Security
|
|
302
324
|
|
|
@@ -6,7 +6,7 @@ This project provides an unofficial Python interface for selected 360 robot vacu
|
|
|
6
6
|
|
|
7
7
|
> [!WARNING]
|
|
8
8
|
> This project is unofficial and not affiliated with Botslab, Qihoo 360 or 360 Smart Home.
|
|
9
|
-
> Version `0.
|
|
9
|
+
> Version `0.3.0` should be considered experimental.
|
|
10
10
|
|
|
11
11
|
## Features
|
|
12
12
|
|
|
@@ -21,15 +21,17 @@ Currently implemented:
|
|
|
21
21
|
- Robot status retrieval
|
|
22
22
|
- TCP / push protocol communication
|
|
23
23
|
- AES decryption of push messages
|
|
24
|
+
- Current room discovery from the robot map
|
|
25
|
+
- Single-room and multi-room cleaning
|
|
24
26
|
- Start cleaning
|
|
25
27
|
- Pause cleaning
|
|
26
28
|
- Resume cleaning
|
|
27
29
|
- Return to dock
|
|
28
30
|
- Locate robot
|
|
29
31
|
|
|
30
|
-
|
|
32
|
+
Device control and room/map support have been verified with:
|
|
31
33
|
|
|
32
|
-
- 360 S9-P
|
|
34
|
+
- 360 S9-P using the 360Robot backend
|
|
33
35
|
|
|
34
36
|
Other models may work but have not yet been verified.
|
|
35
37
|
|
|
@@ -141,7 +143,7 @@ async def main() -> None:
|
|
|
141
143
|
print(device.name)
|
|
142
144
|
print(device.model)
|
|
143
145
|
|
|
144
|
-
status = await client.get_status(device)
|
|
146
|
+
status = await client.get_status(device.id)
|
|
145
147
|
|
|
146
148
|
print(f"Battery: {status.battery}%")
|
|
147
149
|
print(f"State: {status.state}")
|
|
@@ -169,6 +171,27 @@ async with Botslab360Client(q, t) as client:
|
|
|
169
171
|
await client.locate(robot)
|
|
170
172
|
```
|
|
171
173
|
|
|
174
|
+
### Room cleaning
|
|
175
|
+
|
|
176
|
+
Room cleaning always fetches the current map before validating and sending the
|
|
177
|
+
selection. Room IDs are device- and map-specific; do not hard-code IDs without
|
|
178
|
+
first reading the current room list.
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
rooms = await client.get_rooms(robot)
|
|
182
|
+
|
|
183
|
+
for room in rooms:
|
|
184
|
+
print(room.id, room.name)
|
|
185
|
+
|
|
186
|
+
await client.clean_rooms(robot, [1])
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Pass multiple IDs to clean several rooms in one request:
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
await client.clean_rooms(robot, [1, 6])
|
|
193
|
+
```
|
|
194
|
+
|
|
172
195
|
## Status information
|
|
173
196
|
|
|
174
197
|
Depending on the robot model, status information may include:
|
|
@@ -274,9 +297,8 @@ Planned future work may include:
|
|
|
274
297
|
- Additional robot models
|
|
275
298
|
- Persistent push connection
|
|
276
299
|
- Fan speed control
|
|
277
|
-
- Room cleaning
|
|
278
300
|
- Zone cleaning
|
|
279
|
-
-
|
|
301
|
+
- Additional map features
|
|
280
302
|
|
|
281
303
|
## Security
|
|
282
304
|
|
|
@@ -16,6 +16,7 @@ from .models import (
|
|
|
16
16
|
Device,
|
|
17
17
|
DeviceIdentity,
|
|
18
18
|
QihooCredentials,
|
|
19
|
+
Room,
|
|
19
20
|
RobotStatus,
|
|
20
21
|
SmartSession,
|
|
21
22
|
)
|
|
@@ -34,6 +35,7 @@ __all__ = [
|
|
|
34
35
|
"InvalidSessionError",
|
|
35
36
|
"QihooCredentials",
|
|
36
37
|
"QucAuthenticationError",
|
|
38
|
+
"Room",
|
|
37
39
|
"RobotStatus",
|
|
38
40
|
"SmartSession",
|
|
39
41
|
"credentials_from_tokens",
|
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
import hashlib
|
|
7
|
+
import json
|
|
6
8
|
from collections.abc import Awaitable, Callable, Mapping
|
|
9
|
+
from contextlib import suppress
|
|
7
10
|
from numbers import Integral
|
|
8
11
|
from typing import Any, TypeVar
|
|
12
|
+
from urllib.parse import quote_plus
|
|
9
13
|
from uuid import uuid4
|
|
10
14
|
|
|
11
15
|
import httpx
|
|
@@ -26,19 +30,40 @@ from .models import (
|
|
|
26
30
|
Device,
|
|
27
31
|
DeviceIdentity,
|
|
28
32
|
QihooCredentials,
|
|
33
|
+
Room,
|
|
29
34
|
RobotStatus,
|
|
30
35
|
SmartSession,
|
|
31
36
|
)
|
|
32
37
|
from .protocol import (
|
|
38
|
+
ANDROID_360_PUSH_CLIENT_VERSION,
|
|
39
|
+
ANDROID_360_PUSH_HEARTBEAT_INTERVAL,
|
|
40
|
+
ANDROID_360_PUSH_HEARTBEAT_TIMEOUT,
|
|
41
|
+
ANDROID_360_PUSH_PORT,
|
|
42
|
+
DEFAULT_PUSH_CLIENT_VERSION,
|
|
33
43
|
DEFAULT_PUSH_HOST,
|
|
44
|
+
DEFAULT_PUSH_HEARTBEAT_INTERVAL,
|
|
45
|
+
DEFAULT_PUSH_HEARTBEAT_TIMEOUT,
|
|
34
46
|
DEFAULT_PUSH_PORT,
|
|
35
47
|
STATUS_INFO_TYPE,
|
|
36
48
|
PushClient,
|
|
37
49
|
)
|
|
38
50
|
from .quc import ANDROID_360_PROFILE, BOTSLAB_CLOUD_PROFILE, QucAuth
|
|
51
|
+
from .rooms import (
|
|
52
|
+
COMPOSITE_INFO_TYPE,
|
|
53
|
+
LOAD_DATA,
|
|
54
|
+
ROOM_CLEANING_PATH,
|
|
55
|
+
RoomMap,
|
|
56
|
+
prepare_area_setting,
|
|
57
|
+
wait_for_room_map,
|
|
58
|
+
)
|
|
39
59
|
|
|
40
60
|
DEVICE_LIST_PATH = "/common/dev/GetList"
|
|
41
61
|
COMMAND_PATH = "/clean/cmd/send"
|
|
62
|
+
ANDROID_APP_VERSION = "11.1.7"
|
|
63
|
+
ANDROID_CHANNEL_ID = "Overseas"
|
|
64
|
+
ANDROID_DEVICE_MANUFACTURER = "Google"
|
|
65
|
+
ANDROID_DEVICE_MODEL = "sdk_gphone64_x86_64"
|
|
66
|
+
ANDROID_ROOM_USER_AGENT = "okhttp/4.10.0"
|
|
42
67
|
_ResultT = TypeVar("_ResultT")
|
|
43
68
|
|
|
44
69
|
|
|
@@ -152,6 +177,9 @@ class Botslab360Client:
|
|
|
152
177
|
timeout=timeout,
|
|
153
178
|
push_host=push_host,
|
|
154
179
|
push_port=push_port,
|
|
180
|
+
push_client_version=DEFAULT_PUSH_CLIENT_VERSION,
|
|
181
|
+
push_heartbeat_timeout=DEFAULT_PUSH_HEARTBEAT_TIMEOUT,
|
|
182
|
+
push_heartbeat_interval=DEFAULT_PUSH_HEARTBEAT_INTERVAL,
|
|
155
183
|
)
|
|
156
184
|
self._quc_auth: QucAuth | None = None
|
|
157
185
|
self._auth_backend: AuthBackend | None = None
|
|
@@ -167,6 +195,9 @@ class Botslab360Client:
|
|
|
167
195
|
timeout: float,
|
|
168
196
|
push_host: str,
|
|
169
197
|
push_port: int,
|
|
198
|
+
push_client_version: str,
|
|
199
|
+
push_heartbeat_timeout: int,
|
|
200
|
+
push_heartbeat_interval: float,
|
|
170
201
|
) -> None:
|
|
171
202
|
self._http_client = http_client or httpx.AsyncClient(timeout=timeout)
|
|
172
203
|
self._owns_http_client = http_client is None
|
|
@@ -174,6 +205,9 @@ class Botslab360Client:
|
|
|
174
205
|
self._language = language
|
|
175
206
|
self._push_host = push_host
|
|
176
207
|
self._push_port = push_port
|
|
208
|
+
self._push_client_version = push_client_version
|
|
209
|
+
self._push_heartbeat_timeout = push_heartbeat_timeout
|
|
210
|
+
self._push_heartbeat_interval = push_heartbeat_interval
|
|
177
211
|
self._auth = BotslabAuth(
|
|
178
212
|
self._http_client,
|
|
179
213
|
base_url=base_url,
|
|
@@ -195,7 +229,7 @@ class Botslab360Client:
|
|
|
195
229
|
language: str = "de_DE",
|
|
196
230
|
timeout: float = 30.0,
|
|
197
231
|
push_host: str = DEFAULT_PUSH_HOST,
|
|
198
|
-
push_port: int =
|
|
232
|
+
push_port: int | None = None,
|
|
199
233
|
) -> "Botslab360Client":
|
|
200
234
|
"""Create a client that obtains Q/T through a headless QUC login."""
|
|
201
235
|
|
|
@@ -204,11 +238,23 @@ class Botslab360Client:
|
|
|
204
238
|
if backend is AuthBackend.BOTSLAB:
|
|
205
239
|
profile = BOTSLAB_CLOUD_PROFILE
|
|
206
240
|
resolved_region = region or "eu1"
|
|
241
|
+
resolved_push_port = (
|
|
242
|
+
DEFAULT_PUSH_PORT if push_port is None else push_port
|
|
243
|
+
)
|
|
244
|
+
push_client_version = DEFAULT_PUSH_CLIENT_VERSION
|
|
245
|
+
push_heartbeat_timeout = DEFAULT_PUSH_HEARTBEAT_TIMEOUT
|
|
246
|
+
push_heartbeat_interval = DEFAULT_PUSH_HEARTBEAT_INTERVAL
|
|
207
247
|
else:
|
|
208
248
|
profile = ANDROID_360_PROFILE
|
|
209
249
|
if region is not None:
|
|
210
250
|
raise ValueError("region is not supported by the ROBOT360 backend")
|
|
211
251
|
resolved_region = None
|
|
252
|
+
resolved_push_port = (
|
|
253
|
+
ANDROID_360_PUSH_PORT if push_port is None else push_port
|
|
254
|
+
)
|
|
255
|
+
push_client_version = ANDROID_360_PUSH_CLIENT_VERSION
|
|
256
|
+
push_heartbeat_timeout = ANDROID_360_PUSH_HEARTBEAT_TIMEOUT
|
|
257
|
+
push_heartbeat_interval = ANDROID_360_PUSH_HEARTBEAT_INTERVAL
|
|
212
258
|
QucAuth.validate_options(
|
|
213
259
|
email=email,
|
|
214
260
|
password=password,
|
|
@@ -222,7 +268,10 @@ class Botslab360Client:
|
|
|
222
268
|
language=language,
|
|
223
269
|
timeout=timeout,
|
|
224
270
|
push_host=push_host,
|
|
225
|
-
push_port=
|
|
271
|
+
push_port=resolved_push_port,
|
|
272
|
+
push_client_version=push_client_version,
|
|
273
|
+
push_heartbeat_timeout=push_heartbeat_timeout,
|
|
274
|
+
push_heartbeat_interval=push_heartbeat_interval,
|
|
226
275
|
)
|
|
227
276
|
identity = device_identity or DeviceIdentity.generate()
|
|
228
277
|
client._credentials = None
|
|
@@ -477,6 +526,9 @@ class Botslab360Client:
|
|
|
477
526
|
self._session.push_key,
|
|
478
527
|
host=self._push_host,
|
|
479
528
|
port=self._push_port,
|
|
529
|
+
client_version=self._push_client_version,
|
|
530
|
+
heartbeat_timeout=self._push_heartbeat_timeout,
|
|
531
|
+
heartbeat_interval=self._push_heartbeat_interval,
|
|
480
532
|
)
|
|
481
533
|
async with push:
|
|
482
534
|
await self._request_status(device_id=device_id, task_id=task_id)
|
|
@@ -486,6 +538,200 @@ class Botslab360Client:
|
|
|
486
538
|
timeout=timeout,
|
|
487
539
|
)
|
|
488
540
|
|
|
541
|
+
async def get_rooms(
|
|
542
|
+
self,
|
|
543
|
+
device: Device | str,
|
|
544
|
+
*,
|
|
545
|
+
timeout: float = 30.0,
|
|
546
|
+
) -> list[Room]:
|
|
547
|
+
"""Return rooms from a freshly requested smart-area map."""
|
|
548
|
+
|
|
549
|
+
device_id = _device_id(device)
|
|
550
|
+
room_map = await self._with_session_refresh(
|
|
551
|
+
lambda: self._get_room_map_once(device_id=device_id, timeout=timeout)
|
|
552
|
+
)
|
|
553
|
+
return list(room_map.rooms)
|
|
554
|
+
|
|
555
|
+
async def clean_rooms(
|
|
556
|
+
self,
|
|
557
|
+
device: Device | str,
|
|
558
|
+
room_ids: list[int],
|
|
559
|
+
*,
|
|
560
|
+
timeout: float = 30.0,
|
|
561
|
+
) -> None:
|
|
562
|
+
"""Start cleaning selected rooms from a freshly requested map."""
|
|
563
|
+
|
|
564
|
+
device_id = _device_id(device)
|
|
565
|
+
if not isinstance(room_ids, list):
|
|
566
|
+
raise ValueError("room_ids must be a list of integers")
|
|
567
|
+
if not room_ids:
|
|
568
|
+
raise ValueError("room_ids must not be empty")
|
|
569
|
+
if any(
|
|
570
|
+
isinstance(room_id, bool) or not isinstance(room_id, int)
|
|
571
|
+
for room_id in room_ids
|
|
572
|
+
):
|
|
573
|
+
raise ValueError("room_ids must contain integers")
|
|
574
|
+
normalized_ids = list(dict.fromkeys(room_ids))
|
|
575
|
+
await self._with_session_refresh(
|
|
576
|
+
lambda: self._clean_rooms_once(
|
|
577
|
+
device_id=device_id,
|
|
578
|
+
room_ids=normalized_ids,
|
|
579
|
+
timeout=timeout,
|
|
580
|
+
)
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
async def _clean_rooms_once(
|
|
584
|
+
self,
|
|
585
|
+
*,
|
|
586
|
+
device_id: str,
|
|
587
|
+
room_ids: list[int],
|
|
588
|
+
timeout: float,
|
|
589
|
+
) -> None:
|
|
590
|
+
room_map = await self._get_room_map_once(
|
|
591
|
+
device_id=device_id,
|
|
592
|
+
timeout=timeout,
|
|
593
|
+
)
|
|
594
|
+
area_setting = prepare_area_setting(room_map, room_ids)
|
|
595
|
+
await self._post_room_cleaning(
|
|
596
|
+
device_id=device_id,
|
|
597
|
+
clean_id=room_map.clean_id,
|
|
598
|
+
area_setting=area_setting,
|
|
599
|
+
task_id=str(uuid4()),
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
async def _get_room_map_once(
|
|
603
|
+
self,
|
|
604
|
+
*,
|
|
605
|
+
device_id: str,
|
|
606
|
+
timeout: float,
|
|
607
|
+
) -> RoomMap:
|
|
608
|
+
if self._session is None:
|
|
609
|
+
raise AuthenticationError(
|
|
610
|
+
"Authentication is required before requesting rooms",
|
|
611
|
+
phase="authentication",
|
|
612
|
+
)
|
|
613
|
+
push = PushClient(
|
|
614
|
+
self._session.sid,
|
|
615
|
+
self._session.push_key,
|
|
616
|
+
host=self._push_host,
|
|
617
|
+
port=self._push_port,
|
|
618
|
+
client_version=self._push_client_version,
|
|
619
|
+
heartbeat_timeout=self._push_heartbeat_timeout,
|
|
620
|
+
heartbeat_interval=self._push_heartbeat_interval,
|
|
621
|
+
)
|
|
622
|
+
async with push:
|
|
623
|
+
waiter = asyncio.create_task(
|
|
624
|
+
wait_for_room_map(
|
|
625
|
+
push,
|
|
626
|
+
device_id=device_id,
|
|
627
|
+
http_client=self._http_client,
|
|
628
|
+
timeout=timeout,
|
|
629
|
+
)
|
|
630
|
+
)
|
|
631
|
+
await asyncio.sleep(0)
|
|
632
|
+
try:
|
|
633
|
+
await self._post_robot_request(
|
|
634
|
+
device_id=device_id,
|
|
635
|
+
info_type=COMPOSITE_INFO_TYPE,
|
|
636
|
+
data=json.dumps(LOAD_DATA, separators=(",", ":")),
|
|
637
|
+
task_id=str(uuid4()),
|
|
638
|
+
operation="room map request",
|
|
639
|
+
)
|
|
640
|
+
return await waiter
|
|
641
|
+
finally:
|
|
642
|
+
if not waiter.done():
|
|
643
|
+
waiter.cancel()
|
|
644
|
+
with suppress(asyncio.CancelledError):
|
|
645
|
+
await waiter
|
|
646
|
+
|
|
647
|
+
async def _post_room_cleaning(
|
|
648
|
+
self,
|
|
649
|
+
*,
|
|
650
|
+
device_id: str,
|
|
651
|
+
clean_id: str,
|
|
652
|
+
area_setting: str,
|
|
653
|
+
task_id: str,
|
|
654
|
+
) -> None:
|
|
655
|
+
if self._session is None or self._credentials is None:
|
|
656
|
+
raise AuthenticationError(
|
|
657
|
+
"Authentication is required before cleaning rooms",
|
|
658
|
+
phase="authentication",
|
|
659
|
+
)
|
|
660
|
+
headers = {
|
|
661
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
662
|
+
"Accept": "*/*",
|
|
663
|
+
"Connection": "keep-alive",
|
|
664
|
+
"Cookie": (
|
|
665
|
+
f"q={self._credentials.q};t={self._credentials.t};"
|
|
666
|
+
f"qid={self._credentials.qid};sid={quote_plus(self._session.sid)}"
|
|
667
|
+
),
|
|
668
|
+
"User-Agent": ANDROID_ROOM_USER_AGENT,
|
|
669
|
+
}
|
|
670
|
+
form = {
|
|
671
|
+
"sn": device_id,
|
|
672
|
+
"cleanId": clean_id,
|
|
673
|
+
"areaSetting": area_setting,
|
|
674
|
+
"taskid": task_id,
|
|
675
|
+
"from": "mpc_and",
|
|
676
|
+
"devType": "3",
|
|
677
|
+
"channel_id": ANDROID_CHANNEL_ID,
|
|
678
|
+
"appVer": ANDROID_APP_VERSION,
|
|
679
|
+
"lang": self._language,
|
|
680
|
+
"model": ANDROID_DEVICE_MODEL,
|
|
681
|
+
"manufacturer": ANDROID_DEVICE_MANUFACTURER,
|
|
682
|
+
}
|
|
683
|
+
try:
|
|
684
|
+
response = await self._http_client.post(
|
|
685
|
+
f"{self._base_url}{ROOM_CLEANING_PATH}",
|
|
686
|
+
data=form,
|
|
687
|
+
headers=headers,
|
|
688
|
+
)
|
|
689
|
+
response.raise_for_status()
|
|
690
|
+
except httpx.HTTPStatusError as exc:
|
|
691
|
+
raise ApiError(
|
|
692
|
+
"Room cleaning returned an HTTP error",
|
|
693
|
+
status_code=exc.response.status_code,
|
|
694
|
+
phase="http",
|
|
695
|
+
) from exc
|
|
696
|
+
except httpx.RequestError as exc:
|
|
697
|
+
raise ApiError("Room cleaning failed", phase="transport") from exc
|
|
698
|
+
|
|
699
|
+
try:
|
|
700
|
+
payload = response.json()
|
|
701
|
+
except ValueError as exc:
|
|
702
|
+
raise ApiError(
|
|
703
|
+
"Room cleaning returned invalid JSON",
|
|
704
|
+
status_code=response.status_code,
|
|
705
|
+
phase="json",
|
|
706
|
+
) from exc
|
|
707
|
+
if not isinstance(payload, dict):
|
|
708
|
+
raise ApiError(
|
|
709
|
+
"Room cleaning returned an invalid response",
|
|
710
|
+
status_code=response.status_code,
|
|
711
|
+
phase="response-validation",
|
|
712
|
+
)
|
|
713
|
+
response_errno = _numeric_api_code(
|
|
714
|
+
payload.get("errno"),
|
|
715
|
+
status_code=response.status_code,
|
|
716
|
+
)
|
|
717
|
+
errno = response_errno
|
|
718
|
+
error_code = None
|
|
719
|
+
if "errorCode" in payload:
|
|
720
|
+
error_code = _numeric_api_code(
|
|
721
|
+
payload["errorCode"],
|
|
722
|
+
status_code=response.status_code,
|
|
723
|
+
)
|
|
724
|
+
if error_code != 0:
|
|
725
|
+
errno = error_code
|
|
726
|
+
if errno != 0:
|
|
727
|
+
_raise_smart_api_error(
|
|
728
|
+
errno,
|
|
729
|
+
operation="room cleaning",
|
|
730
|
+
status_code=response.status_code,
|
|
731
|
+
response_errno=response_errno,
|
|
732
|
+
error_code=error_code,
|
|
733
|
+
)
|
|
734
|
+
|
|
489
735
|
async def start_cleaning(self, device: Device | str) -> None:
|
|
490
736
|
"""Request one whole-home smart cleaning run."""
|
|
491
737
|
|
|
@@ -101,6 +101,18 @@ class Device:
|
|
|
101
101
|
online: bool
|
|
102
102
|
|
|
103
103
|
|
|
104
|
+
@dataclass(frozen=True, slots=True)
|
|
105
|
+
class Room:
|
|
106
|
+
"""A room from the robot's current smart-area map."""
|
|
107
|
+
|
|
108
|
+
id: int
|
|
109
|
+
name: str
|
|
110
|
+
room_type: str | None
|
|
111
|
+
clean_times: int | None
|
|
112
|
+
fan_mode: str | None
|
|
113
|
+
water_pump: int | None
|
|
114
|
+
|
|
115
|
+
|
|
104
116
|
@dataclass(frozen=True, slots=True)
|
|
105
117
|
class RobotStatus:
|
|
106
118
|
"""Read-only status reported by a Botslab/360 robot."""
|