python-roborock 2.6.1__tar.gz → 2.8.1__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 (28) hide show
  1. {python_roborock-2.6.1 → python_roborock-2.8.1}/PKG-INFO +3 -3
  2. {python_roborock-2.6.1 → python_roborock-2.8.1}/pyproject.toml +4 -3
  3. {python_roborock-2.6.1 → python_roborock-2.8.1}/roborock/api.py +8 -1
  4. {python_roborock-2.6.1 → python_roborock-2.8.1}/roborock/code_mappings.py +31 -0
  5. {python_roborock-2.6.1 → python_roborock-2.8.1}/roborock/const.py +1 -0
  6. {python_roborock-2.6.1 → python_roborock-2.8.1}/roborock/containers.py +76 -11
  7. {python_roborock-2.6.1 → python_roborock-2.8.1}/roborock/roborock_message.py +3 -3
  8. {python_roborock-2.6.1 → python_roborock-2.8.1}/roborock/roborock_typing.py +1 -0
  9. {python_roborock-2.6.1 → python_roborock-2.8.1}/roborock/util.py +12 -0
  10. {python_roborock-2.6.1 → python_roborock-2.8.1}/roborock/version_1_apis/roborock_client_v1.py +8 -5
  11. {python_roborock-2.6.1 → python_roborock-2.8.1}/roborock/version_1_apis/roborock_mqtt_client_v1.py +16 -1
  12. {python_roborock-2.6.1 → python_roborock-2.8.1}/LICENSE +0 -0
  13. {python_roborock-2.6.1 → python_roborock-2.8.1}/README.md +0 -0
  14. {python_roborock-2.6.1 → python_roborock-2.8.1}/roborock/__init__.py +0 -0
  15. {python_roborock-2.6.1 → python_roborock-2.8.1}/roborock/cli.py +0 -0
  16. {python_roborock-2.6.1 → python_roborock-2.8.1}/roborock/cloud_api.py +0 -0
  17. {python_roborock-2.6.1 → python_roborock-2.8.1}/roborock/command_cache.py +0 -0
  18. {python_roborock-2.6.1 → python_roborock-2.8.1}/roborock/exceptions.py +0 -0
  19. {python_roborock-2.6.1 → python_roborock-2.8.1}/roborock/local_api.py +0 -0
  20. {python_roborock-2.6.1 → python_roborock-2.8.1}/roborock/protocol.py +0 -0
  21. {python_roborock-2.6.1 → python_roborock-2.8.1}/roborock/py.typed +0 -0
  22. {python_roborock-2.6.1 → python_roborock-2.8.1}/roborock/roborock_future.py +0 -0
  23. {python_roborock-2.6.1 → python_roborock-2.8.1}/roborock/version_1_apis/__init__.py +0 -0
  24. {python_roborock-2.6.1 → python_roborock-2.8.1}/roborock/version_1_apis/roborock_local_client_v1.py +0 -0
  25. {python_roborock-2.6.1 → python_roborock-2.8.1}/roborock/version_a01_apis/__init__.py +0 -0
  26. {python_roborock-2.6.1 → python_roborock-2.8.1}/roborock/version_a01_apis/roborock_client_a01.py +0 -0
  27. {python_roborock-2.6.1 → python_roborock-2.8.1}/roborock/version_a01_apis/roborock_mqtt_client_a01.py +0 -0
  28. {python_roborock-2.6.1 → python_roborock-2.8.1}/roborock/web_api.py +0 -0
@@ -1,20 +1,19 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-roborock
3
- Version: 2.6.1
3
+ Version: 2.8.1
4
4
  Summary: A package to control Roborock vacuums.
5
5
  Home-page: https://github.com/humbertogontijo/python-roborock
6
6
  License: GPL-3.0-only
7
7
  Keywords: roborock,vacuum,homeassistant
8
8
  Author: humbertogontijo
9
9
  Author-email: humbertogontijo@users.noreply.github.com
10
- Requires-Python: >=3.10,<4.0
10
+ Requires-Python: >=3.11,<4.0
11
11
  Classifier: Development Status :: 5 - Production/Stable
12
12
  Classifier: Intended Audience :: Developers
13
13
  Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
14
14
  Classifier: Natural Language :: English
15
15
  Classifier: Operating System :: OS Independent
16
16
  Classifier: Programming Language :: Python :: 3
17
- Classifier: Programming Language :: Python :: 3.10
18
17
  Classifier: Programming Language :: Python :: 3.11
19
18
  Classifier: Programming Language :: Python :: 3.12
20
19
  Classifier: Programming Language :: Python :: 3.13
@@ -27,6 +26,7 @@ Requires-Dist: dacite (>=1.8.0,<2.0.0)
27
26
  Requires-Dist: paho-mqtt (>=1.6.1,<2.0.0)
28
27
  Requires-Dist: pycryptodome (>=3.18,<4.0)
29
28
  Requires-Dist: pycryptodomex (>=3.18,<4.0) ; sys_platform == "darwin"
29
+ Requires-Dist: vacuum-map-parser-roborock
30
30
  Project-URL: Documentation, https://python-roborock.readthedocs.io/
31
31
  Project-URL: Repository, https://github.com/humbertogontijo/python-roborock
32
32
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-roborock"
3
- version = "2.6.1"
3
+ version = "2.8.1"
4
4
  description = "A package to control Roborock vacuums."
5
5
  authors = ["humbertogontijo <humbertogontijo@users.noreply.github.com>"]
6
6
  license = "GPL-3.0-only"
@@ -21,7 +21,7 @@ keywords = ["roborock", "vacuum", "homeassistant"]
21
21
  roborock = "roborock.cli:main"
22
22
 
23
23
  [tool.poetry.dependencies]
24
- python = "^3.10"
24
+ python = "^3.11"
25
25
  click = ">=8"
26
26
  aiohttp = "^3.8.2"
27
27
  async-timeout = "*"
@@ -30,6 +30,7 @@ pycryptodomex = {version = "^3.18", markers = "sys_platform == 'darwin'"}
30
30
  paho-mqtt = "^1.6.1"
31
31
  dacite = "^1.8.0"
32
32
  construct = "^2.10.57"
33
+ vacuum-map-parser-roborock = "*"
33
34
 
34
35
 
35
36
  [build-system]
@@ -47,7 +48,7 @@ pyshark = "^0.6"
47
48
 
48
49
  [tool.semantic_release]
49
50
  branch = "main"
50
- version_toml = "pyproject.toml:tool.poetry.version"
51
+ version_toml = ["pyproject.toml:tool.poetry.version"]
51
52
  build_command = "pip install poetry && poetry build"
52
53
  [tool.semantic_release.commit_parser_options]
53
54
  allowed_tags = [
@@ -23,7 +23,7 @@ from .roborock_message import (
23
23
  RoborockMessage,
24
24
  )
25
25
  from .roborock_typing import RoborockCommand
26
- from .util import RoborockLoggerAdapter, get_running_loop_or_create_one
26
+ from .util import RoborockLoggerAdapter, get_next_int, get_running_loop_or_create_one
27
27
 
28
28
  _LOGGER = logging.getLogger(__name__)
29
29
  KEEPALIVE = 60
@@ -113,6 +113,13 @@ class RoborockClient:
113
113
  self, request_id: int, protocol_id: int = 0
114
114
  ) -> Coroutine[Any, Any, tuple[Any, VacuumError | None]]:
115
115
  queue = RoborockFuture(protocol_id)
116
+ if request_id in self._waiting_queue:
117
+ new_id = get_next_int(10000, 32767)
118
+ _LOGGER.warning(
119
+ f"Attempting to create a future with an existing request_id... New id is {new_id}. "
120
+ f"Code may not function properly."
121
+ )
122
+ request_id = new_id
116
123
  self._waiting_queue[request_id] = queue
117
124
  return self._wait_response(request_id, queue)
118
125
 
@@ -243,6 +243,15 @@ class RoborockFanSpeedQ7Max(RoborockFanPowerCode):
243
243
  max = 104
244
244
 
245
245
 
246
+ class RoborockFanSpeedQRevoMaster(RoborockFanPowerCode):
247
+ quiet = 101
248
+ balanced = 102
249
+ turbo = 103
250
+ max = 104
251
+ max_plus = 105
252
+ custom = 110 # Smartplan
253
+
254
+
246
255
  class RoborockFanSpeedP10(RoborockFanPowerCode):
247
256
  off = 105
248
257
  quiet = 101
@@ -288,6 +297,7 @@ class RoborockMopModeS8ProUltra(RoborockMopModeCode):
288
297
  class RoborockMopModeS8MaxVUltra(RoborockMopModeCode):
289
298
  standard = 300
290
299
  deep = 301
300
+ custom = 302
291
301
  deep_plus = 303
292
302
  fast = 304
293
303
  deep_plus_pearl = 305
@@ -318,6 +328,17 @@ class RoborockMopIntensityV2(RoborockMopIntensityCode):
318
328
  custom = 207
319
329
 
320
330
 
331
+ class RoborockMopIntensityQRevoMaster(RoborockMopIntensityCode):
332
+ """Describes the mop intensity of the vacuum cleaner."""
333
+
334
+ off = 200
335
+ low = 201
336
+ medium = 202
337
+ high = 203
338
+ custom_water_flow = 207
339
+ custom = 209 # SmartPlan
340
+
341
+
321
342
  class RoborockMopIntensityP10(RoborockMopIntensityCode):
322
343
  """Describes the mop intensity of the vacuum cleaner."""
323
344
 
@@ -362,6 +383,16 @@ class RoborockMopIntensityS6MaxV(RoborockMopIntensityCode):
362
383
  custom_water_flow = 207
363
384
 
364
385
 
386
+ class RoborockMopIntensityQ7Max(RoborockMopIntensityCode):
387
+ """Describes the mop intensity of the vacuum cleaner."""
388
+
389
+ off = 200
390
+ low = 201
391
+ medium = 202
392
+ high = 203
393
+ custom_water_flow = 207
394
+
395
+
365
396
  class RoborockDockErrorCode(RoborockEnum):
366
397
  """Describes the error code of the dock."""
367
398
 
@@ -30,6 +30,7 @@ ROBOROCK_Q5_PRO = "roborock.vacuum.a72"
30
30
  ROBOROCK_Q7 = "roborock.vacuum.a40"
31
31
  ROBOROCK_Q7_MAX = "roborock.vacuum.a38"
32
32
  ROBOROCK_Q7PLUS = "roborock.vacuum.a40"
33
+ ROBOROCK_QREVO_MASTER = "roborock.vacuum.a117"
33
34
  ROBOROCK_Q8_MAX = "roborock.vacuum.a73"
34
35
  ROBOROCK_G10S_PRO = "roborock.vacuum.a26"
35
36
  ROBOROCK_G10S = "roborock.vacuum.a46"
@@ -7,9 +7,7 @@ import re
7
7
  from dataclasses import asdict, dataclass, field
8
8
  from datetime import timezone
9
9
  from enum import Enum
10
- from typing import Any, NamedTuple
11
-
12
- from dacite import Config, from_dict
10
+ from typing import Any, NamedTuple, get_args, get_origin
13
11
 
14
12
  from .code_mappings import (
15
13
  RoborockCategory,
@@ -22,6 +20,7 @@ from .code_mappings import (
22
20
  RoborockFanPowerCode,
23
21
  RoborockFanSpeedP10,
24
22
  RoborockFanSpeedQ7Max,
23
+ RoborockFanSpeedQRevoMaster,
25
24
  RoborockFanSpeedS6Pure,
26
25
  RoborockFanSpeedS7,
27
26
  RoborockFanSpeedS7MaxV,
@@ -30,11 +29,12 @@ from .code_mappings import (
30
29
  RoborockInCleaning,
31
30
  RoborockMopIntensityCode,
32
31
  RoborockMopIntensityP10,
32
+ RoborockMopIntensityQ7Max,
33
+ RoborockMopIntensityQRevoMaster,
33
34
  RoborockMopIntensityS5Max,
34
35
  RoborockMopIntensityS6MaxV,
35
36
  RoborockMopIntensityS7,
36
37
  RoborockMopIntensityS8MaxVUltra,
37
- RoborockMopIntensityV2,
38
38
  RoborockMopModeCode,
39
39
  RoborockMopModeS7,
40
40
  RoborockMopModeS8MaxVUltra,
@@ -51,6 +51,7 @@ from .const import (
51
51
  ROBOROCK_G10S_PRO,
52
52
  ROBOROCK_P10,
53
53
  ROBOROCK_Q7_MAX,
54
+ ROBOROCK_QREVO_MASTER,
54
55
  ROBOROCK_QREVO_MAXV,
55
56
  ROBOROCK_QREVO_PRO,
56
57
  ROBOROCK_QREVO_S,
@@ -102,14 +103,70 @@ class RoborockBase:
102
103
  _ignore_keys = [] # type: ignore
103
104
  is_cached = False
104
105
 
106
+ @staticmethod
107
+ def convert_to_class_obj(type, value):
108
+ try:
109
+ class_type = eval(type)
110
+ if get_origin(class_type) is list:
111
+ return_list = []
112
+ cls_type = get_args(class_type)[0]
113
+ for obj in value:
114
+ if issubclass(cls_type, RoborockBase):
115
+ return_list.append(cls_type.from_dict(obj))
116
+ elif cls_type in {str, int, float}:
117
+ return_list.append(cls_type(obj))
118
+ else:
119
+ return_list.append(cls_type(**obj))
120
+ return return_list
121
+ if issubclass(class_type, RoborockBase):
122
+ converted_value = class_type.from_dict(value)
123
+ else:
124
+ converted_value = class_type(value)
125
+ return converted_value
126
+ except NameError as err:
127
+ _LOGGER.exception(err)
128
+ except ValueError as err:
129
+ _LOGGER.exception(err)
130
+ except Exception as err:
131
+ _LOGGER.exception(err)
132
+ raise Exception("Fail")
133
+
105
134
  @classmethod
106
135
  def from_dict(cls, data: dict[str, Any]):
107
136
  if isinstance(data, dict):
108
137
  ignore_keys = cls._ignore_keys
109
- try:
110
- return from_dict(cls, decamelize_obj(data, ignore_keys), config=Config(cast=[Enum]))
111
- except AttributeError as err:
112
- raise RoborockException("It seems like you have an outdated version of dacite.") from err
138
+ data = decamelize_obj(data, ignore_keys)
139
+ cls_annotations: dict[str, str] = {}
140
+ for base in reversed(cls.__mro__):
141
+ cls_annotations.update(getattr(base, "__annotations__", {}))
142
+ remove_keys = []
143
+ for key, value in data.items():
144
+ if key not in cls_annotations:
145
+ remove_keys.append(key)
146
+ continue
147
+ if value == "None" or value is None:
148
+ data[key] = None
149
+ continue
150
+ field_type: str = cls_annotations[key]
151
+ if "|" in field_type:
152
+ # It's a union
153
+ types = field_type.split("|")
154
+ for type in types:
155
+ if "None" in type or "Any" in type:
156
+ continue
157
+ try:
158
+ data[key] = RoborockBase.convert_to_class_obj(type, value)
159
+ break
160
+ except Exception:
161
+ ...
162
+ else:
163
+ try:
164
+ data[key] = RoborockBase.convert_to_class_obj(field_type, value)
165
+ except Exception:
166
+ ...
167
+ for key in remove_keys:
168
+ del data[key]
169
+ return cls(**data)
113
170
 
114
171
  def as_dict(self) -> dict:
115
172
  return asdict(
@@ -185,6 +242,7 @@ class HomeDataProductSchema(RoborockBase):
185
242
  mode: Any | None = None
186
243
  type: Any | None = None
187
244
  product_property: Any | None = None
245
+ property: Any | None = None
188
246
  desc: Any | None = None
189
247
 
190
248
 
@@ -195,7 +253,7 @@ class HomeDataProduct(RoborockBase):
195
253
  model: str
196
254
  category: RoborockCategory
197
255
  code: str | None = None
198
- iconurl: str | None = None
256
+ icon_url: str | None = None
199
257
  attribute: Any | None = None
200
258
  capability: int | None = None
201
259
  schema: list[HomeDataProductSchema] | None = None
@@ -512,7 +570,13 @@ class S5MaxStatus(Status):
512
570
  @dataclass
513
571
  class Q7MaxStatus(Status):
514
572
  fan_power: RoborockFanSpeedQ7Max | None = None
515
- water_box_mode: RoborockMopIntensityV2 | None = None
573
+ water_box_mode: RoborockMopIntensityQ7Max | None = None
574
+
575
+
576
+ @dataclass
577
+ class QRevoMasterStatus(Status):
578
+ fan_power: RoborockFanSpeedQRevoMaster | None = None
579
+ water_box_mode: RoborockMopIntensityQRevoMaster | None = None
516
580
 
517
581
 
518
582
  @dataclass
@@ -572,6 +636,7 @@ ModelStatus: dict[str, type[Status]] = {
572
636
  ROBOROCK_S4_MAX: S4MaxStatus,
573
637
  ROBOROCK_S5_MAX: S5MaxStatus,
574
638
  ROBOROCK_Q7_MAX: Q7MaxStatus,
639
+ ROBOROCK_QREVO_MASTER: QRevoMasterStatus,
575
640
  ROBOROCK_S6: S6PureStatus,
576
641
  ROBOROCK_S6_MAXV: S6MaxVStatus,
577
642
  ROBOROCK_S6_PURE: S6PureStatus,
@@ -612,7 +677,7 @@ class CleanSummary(RoborockBase):
612
677
  last_clean_t: int | None = None
613
678
 
614
679
  def __post_init__(self) -> None:
615
- if isinstance(self.clean_area, list):
680
+ if isinstance(self.clean_area, list | str):
616
681
  _LOGGER.warning(f"Clean area is a unexpected type! Please give the following in a issue: {self.clean_area}")
617
682
  else:
618
683
  self.square_meter_clean_area = round(self.clean_area / 1000000, 1) if self.clean_area is not None else None
@@ -4,9 +4,9 @@ import json
4
4
  import math
5
5
  import time
6
6
  from dataclasses import dataclass
7
- from random import randint
8
7
 
9
8
  from roborock import RoborockEnum
9
+ from roborock.util import get_next_int
10
10
 
11
11
 
12
12
  class RoborockMessageProtocol(RoborockEnum):
@@ -155,9 +155,9 @@ class MessageRetry:
155
155
  class RoborockMessage:
156
156
  protocol: RoborockMessageProtocol
157
157
  payload: bytes | None = None
158
- seq: int = randint(100000, 999999)
158
+ seq: int = get_next_int(100000, 999999)
159
159
  version: bytes = b"1.0"
160
- random: int = randint(10000, 99999)
160
+ random: int = get_next_int(10000, 99999)
161
161
  timestamp: int = math.floor(time.time())
162
162
  message_retry: MessageRetry | None = None
163
163
 
@@ -116,6 +116,7 @@ class RoborockCommand(str, Enum):
116
116
  GET_MAP_STATUS = "get_map_status"
117
117
  GET_MAP_V1 = "get_map_v1"
118
118
  GET_MAP_V2 = "get_map_v2"
119
+ GET_MAP_CALIBRATION = "get_map_calibration" # Custom command
119
120
  GET_MOP_MOTOR_STATUS = "get_mop_motor_status"
120
121
  GET_MOP_TEMPLATE_PARAMS_BY_ID = "get_mop_template_params_by_id"
121
122
  GET_MOP_TEMPLATE_PARAMS_SUMMARY = "get_mop_template_params_summary"
@@ -108,3 +108,15 @@ class RoborockLoggerAdapter(logging.LoggerAdapter):
108
108
 
109
109
  def process(self, msg: str, kwargs: MutableMapping[str, Any]) -> tuple[str, MutableMapping[str, Any]]:
110
110
  return f"[{self.prefix}] {msg}", kwargs
111
+
112
+
113
+ counter_map: dict[tuple[int, int], int] = {}
114
+
115
+
116
+ def get_next_int(min_val: int, max_val: int):
117
+ """Gets a random int in the range, precached to help keep it fast."""
118
+ if (min_val, max_val) not in counter_map:
119
+ # If we have never seen this range, or if the cache is getting low, make a bunch of preshuffled values.
120
+ counter_map[(min_val, max_val)] = min_val
121
+ counter_map[(min_val, max_val)] += 1
122
+ return counter_map[(min_val, max_val)] % max_val + min_val
@@ -5,7 +5,6 @@ import math
5
5
  import struct
6
6
  import time
7
7
  from collections.abc import Callable, Coroutine
8
- from random import randint
9
8
  from typing import Any, TypeVar, final
10
9
 
11
10
  from roborock import (
@@ -54,12 +53,16 @@ from roborock.roborock_message import (
54
53
  RoborockMessage,
55
54
  RoborockMessageProtocol,
56
55
  )
57
- from roborock.util import RepeatableTask, unpack_list
56
+ from roborock.util import RepeatableTask, get_next_int, unpack_list
58
57
 
59
- COMMANDS_SECURED = [
58
+ COMMANDS_SECURED = {
60
59
  RoborockCommand.GET_MAP_V1,
61
60
  RoborockCommand.GET_MULTI_MAP,
62
- ]
61
+ }
62
+
63
+ CUSTOM_COMMANDS = {RoborockCommand.GET_MAP_CALIBRATION}
64
+
65
+ CLOUD_REQUIRED = COMMANDS_SECURED.union(CUSTOM_COMMANDS)
63
66
 
64
67
  WASH_N_FILL_DOCK = [
65
68
  RoborockDockTypeCode.empty_wash_fill_dock,
@@ -334,7 +337,7 @@ class RoborockClientV1(RoborockClient):
334
337
  secured=False,
335
338
  ):
336
339
  timestamp = math.floor(time.time())
337
- request_id = randint(10000, 32767)
340
+ request_id = get_next_int(10000, 32767)
338
341
  inner = {
339
342
  "id": request_id,
340
343
  "method": method,
@@ -2,6 +2,10 @@ import asyncio
2
2
  import base64
3
3
 
4
4
  import paho.mqtt.client as mqtt
5
+ from vacuum_map_parser_base.config.color import ColorsPalette
6
+ from vacuum_map_parser_base.config.image_config import ImageConfig
7
+ from vacuum_map_parser_base.config.size import Sizes
8
+ from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser
5
9
 
6
10
  from roborock.cloud_api import RoborockMqttClient
7
11
 
@@ -13,7 +17,7 @@ from ..roborock_message import (
13
17
  RoborockMessageProtocol,
14
18
  )
15
19
  from ..roborock_typing import RoborockCommand
16
- from .roborock_client_v1 import COMMANDS_SECURED, RoborockClientV1
20
+ from .roborock_client_v1 import COMMANDS_SECURED, CUSTOM_COMMANDS, RoborockClientV1
17
21
 
18
22
 
19
23
  class RoborockMqttClientV1(RoborockMqttClient, RoborockClientV1):
@@ -66,10 +70,21 @@ class RoborockMqttClientV1(RoborockMqttClient, RoborockClientV1):
66
70
  method: RoborockCommand | str,
67
71
  params: list | dict | int | None = None,
68
72
  ):
73
+ if method in CUSTOM_COMMANDS:
74
+ # When we have more custom commands do something more complicated here
75
+ return await self._get_calibration_points()
69
76
  request_id, timestamp, payload = self._get_payload(method, params, True)
70
77
  request_protocol = RoborockMessageProtocol.RPC_REQUEST
71
78
  roborock_message = RoborockMessage(timestamp=timestamp, protocol=request_protocol, payload=payload)
72
79
  return await self.send_message(roborock_message)
73
80
 
81
+ async def _get_calibration_points(self):
82
+ map: bytes = await self.send_command(RoborockCommand.GET_MAP_V1)
83
+ parser = RoborockMapDataParser(ColorsPalette(), Sizes(), [], ImageConfig(), [])
84
+ parsed_map = parser.parse(map)
85
+ calibration = parsed_map.calibration()
86
+ self._logger.info(parsed_map.calibration())
87
+ return calibration
88
+
74
89
  async def get_map_v1(self) -> bytes | None:
75
90
  return await self.send_command(RoborockCommand.GET_MAP_V1)
File without changes