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.
Files changed (36) hide show
  1. {botslab360-0.2.0/src/botslab360.egg-info → botslab360-0.3.0}/PKG-INFO +29 -7
  2. {botslab360-0.2.0 → botslab360-0.3.0}/README.md +28 -6
  3. {botslab360-0.2.0 → botslab360-0.3.0}/pyproject.toml +1 -1
  4. {botslab360-0.2.0 → botslab360-0.3.0}/src/botslab360/__init__.py +2 -0
  5. {botslab360-0.2.0 → botslab360-0.3.0}/src/botslab360/client.py +248 -2
  6. {botslab360-0.2.0 → botslab360-0.3.0}/src/botslab360/models.py +12 -0
  7. botslab360-0.3.0/src/botslab360/protocol.py +715 -0
  8. botslab360-0.3.0/src/botslab360/rooms.py +303 -0
  9. {botslab360-0.2.0 → botslab360-0.3.0/src/botslab360.egg-info}/PKG-INFO +29 -7
  10. {botslab360-0.2.0 → botslab360-0.3.0}/src/botslab360.egg-info/SOURCES.txt +3 -0
  11. botslab360-0.3.0/tests/test_protocol.py +960 -0
  12. botslab360-0.3.0/tests/test_public_room_api_diagnostic.py +257 -0
  13. {botslab360-0.2.0 → botslab360-0.3.0}/tests/test_quc.py +10 -0
  14. botslab360-0.3.0/tests/test_room_diagnostic.py +1265 -0
  15. botslab360-0.3.0/tests/test_rooms.py +373 -0
  16. {botslab360-0.2.0 → botslab360-0.3.0}/tests/test_status.py +15 -2
  17. botslab360-0.2.0/src/botslab360/protocol.py +0 -311
  18. botslab360-0.2.0/tests/test_protocol.py +0 -180
  19. botslab360-0.2.0/tests/test_room_diagnostic.py +0 -132
  20. {botslab360-0.2.0 → botslab360-0.3.0}/ATTRIBUTION.md +0 -0
  21. {botslab360-0.2.0 → botslab360-0.3.0}/LICENSE +0 -0
  22. {botslab360-0.2.0 → botslab360-0.3.0}/setup.cfg +0 -0
  23. {botslab360-0.2.0 → botslab360-0.3.0}/src/botslab360/auth.py +0 -0
  24. {botslab360-0.2.0 → botslab360-0.3.0}/src/botslab360/commands.py +0 -0
  25. {botslab360-0.2.0 → botslab360-0.3.0}/src/botslab360/exceptions.py +0 -0
  26. {botslab360-0.2.0 → botslab360-0.3.0}/src/botslab360/quc.py +0 -0
  27. {botslab360-0.2.0 → botslab360-0.3.0}/src/botslab360.egg-info/dependency_links.txt +0 -0
  28. {botslab360-0.2.0 → botslab360-0.3.0}/src/botslab360.egg-info/requires.txt +0 -0
  29. {botslab360-0.2.0 → botslab360-0.3.0}/src/botslab360.egg-info/top_level.txt +0 -0
  30. {botslab360-0.2.0 → botslab360-0.3.0}/tests/test_android_360_diagnostic.py +0 -0
  31. {botslab360-0.2.0 → botslab360-0.3.0}/tests/test_auth.py +0 -0
  32. {botslab360-0.2.0 → botslab360-0.3.0}/tests/test_commands.py +0 -0
  33. {botslab360-0.2.0 → botslab360-0.3.0}/tests/test_compare_android_quc.py +0 -0
  34. {botslab360-0.2.0 → botslab360-0.3.0}/tests/test_credentials_auth_example.py +0 -0
  35. {botslab360-0.2.0 → botslab360-0.3.0}/tests/test_devices.py +0 -0
  36. {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.2.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.2.0` should be considered experimental.
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
- Currently tested with:
52
+ Device control and room/map support have been verified with:
51
53
 
52
- - 360 S9-P / X90
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
- - Map support
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.2.0` should be considered experimental.
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
- Currently tested with:
32
+ Device control and room/map support have been verified with:
31
33
 
32
- - 360 S9-P / X90
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
- - Map support
301
+ - Additional map features
280
302
 
281
303
  ## Security
282
304
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "botslab360"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Async Python client for Botslab/360 robot vacuums"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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 = DEFAULT_PUSH_PORT,
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=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."""