python-roborock 5.0.0__tar.gz → 5.2.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 (103) hide show
  1. {python_roborock-5.0.0 → python_roborock-5.2.0}/PKG-INFO +3 -2
  2. {python_roborock-5.0.0 → python_roborock-5.2.0}/pyproject.toml +9 -2
  3. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/broadcast_protocol.py +0 -2
  4. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/data/code_mappings.py +6 -8
  5. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/data/v1/v1_code_mappings.py +3 -1
  6. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/device_features.py +2 -4
  7. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/device_manager.py +1 -1
  8. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/rpc/b01_q10_channel.py +0 -2
  9. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/rpc/b01_q7_channel.py +39 -17
  10. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/b01/q7/__init__.py +38 -10
  11. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/b01/q7/clean_summary.py +0 -2
  12. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/b01/q7/map.py +6 -28
  13. python_roborock-5.2.0/roborock/devices/traits/b01/q7/map_content.py +100 -0
  14. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/network_info.py +0 -2
  15. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/diagnostics.py +4 -6
  16. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/exceptions.py +0 -2
  17. python_roborock-5.2.0/roborock/map/b01_map_parser.py +124 -0
  18. python_roborock-5.2.0/roborock/map/proto/__init__.py +1 -0
  19. python_roborock-5.2.0/roborock/map/proto/b01_scmap.proto +70 -0
  20. python_roborock-5.2.0/roborock/map/proto/b01_scmap_pb2.py +48 -0
  21. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/protocol.py +0 -2
  22. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/protocols/b01_q7_protocol.py +50 -0
  23. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/roborock_message.py +3 -4
  24. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/roborock_typing.py +2 -3
  25. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/util.py +0 -2
  26. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/web_api.py +0 -2
  27. {python_roborock-5.0.0 → python_roborock-5.2.0}/.gitignore +0 -0
  28. {python_roborock-5.0.0 → python_roborock-5.2.0}/LICENSE +0 -0
  29. {python_roborock-5.0.0 → python_roborock-5.2.0}/README.md +0 -0
  30. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/__init__.py +0 -0
  31. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/callbacks.py +0 -0
  32. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/cli.py +0 -0
  33. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/const.py +0 -0
  34. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/data/__init__.py +0 -0
  35. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/data/b01_q10/__init__.py +0 -0
  36. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
  37. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
  38. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/data/b01_q7/__init__.py +0 -0
  39. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  40. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  41. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/data/containers.py +0 -0
  42. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/data/dyad/__init__.py +0 -0
  43. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  44. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/data/dyad/dyad_containers.py +0 -0
  45. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/data/v1/__init__.py +0 -0
  46. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/data/v1/v1_clean_modes.py +0 -0
  47. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/data/v1/v1_containers.py +0 -0
  48. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/data/zeo/__init__.py +0 -0
  49. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  50. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/data/zeo/zeo_containers.py +0 -0
  51. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/README.md +0 -0
  52. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/__init__.py +0 -0
  53. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/cache.py +0 -0
  54. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/device.py +0 -0
  55. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/file_cache.py +0 -0
  56. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/rpc/__init__.py +0 -0
  57. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/rpc/a01_channel.py +0 -0
  58. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/rpc/v1_channel.py +0 -0
  59. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/__init__.py +0 -0
  60. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/a01/__init__.py +0 -0
  61. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/b01/__init__.py +0 -0
  62. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/b01/q10/__init__.py +0 -0
  63. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/b01/q10/command.py +0 -0
  64. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/b01/q10/common.py +0 -0
  65. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/b01/q10/status.py +0 -0
  66. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
  67. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/traits_mixin.py +0 -0
  68. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/__init__.py +0 -0
  69. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/child_lock.py +0 -0
  70. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/clean_summary.py +0 -0
  71. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/command.py +0 -0
  72. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/common.py +0 -0
  73. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/consumeable.py +0 -0
  74. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/device_features.py +0 -0
  75. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  76. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  77. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  78. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/home.py +0 -0
  79. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/led_status.py +0 -0
  80. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/map_content.py +0 -0
  81. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/maps.py +0 -0
  82. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/rooms.py +0 -0
  83. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/routines.py +0 -0
  84. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  85. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/status.py +0 -0
  86. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  87. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/volume.py +0 -0
  88. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  89. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/transport/__init__.py +0 -0
  90. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/transport/channel.py +0 -0
  91. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/transport/local_channel.py +0 -0
  92. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/devices/transport/mqtt_channel.py +0 -0
  93. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/map/__init__.py +0 -0
  94. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/map/map_parser.py +0 -0
  95. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/mqtt/__init__.py +0 -0
  96. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/mqtt/health_manager.py +0 -0
  97. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/mqtt/roborock_session.py +0 -0
  98. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/mqtt/session.py +0 -0
  99. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/protocols/__init__.py +0 -0
  100. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/protocols/a01_protocol.py +0 -0
  101. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/protocols/b01_q10_protocol.py +0 -0
  102. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/protocols/v1_protocol.py +0 -0
  103. {python_roborock-5.0.0 → python_roborock-5.2.0}/roborock/py.typed +0 -0
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-roborock
3
- Version: 5.0.0
3
+ Version: 5.2.0
4
4
  Summary: A package to control Roborock vacuums.
5
- Project-URL: Repository, https://github.com/humbertogontijo/python-roborock
5
+ Project-URL: Repository, https://github.com/python-roborock/python-roborock
6
6
  Project-URL: Documentation, https://python-roborock.readthedocs.io/
7
7
  Author: Lash-L, allenporter
8
8
  Author-email: humbertogontijo <humbertogontijo@users.noreply.github.com>
@@ -21,6 +21,7 @@ Requires-Dist: click-shell~=2.1
21
21
  Requires-Dist: click>=8
22
22
  Requires-Dist: construct<3,>=2.10.57
23
23
  Requires-Dist: paho-mqtt<3.0.0,>=1.6.1
24
+ Requires-Dist: protobuf<7,>=5
24
25
  Requires-Dist: pycryptodomex~=3.18; sys_platform == 'darwin'
25
26
  Requires-Dist: pycryptodome~=3.18
26
27
  Requires-Dist: pyrate-limiter<5,>=4.0.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-roborock"
3
- version = "5.0.0"
3
+ version = "5.2.0"
4
4
  description = "A package to control Roborock vacuums."
5
5
  authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}]
6
6
  requires-python = ">=3.11, <4"
@@ -25,6 +25,7 @@ dependencies = [
25
25
  "pycryptodomex~=3.18 ; sys_platform == 'darwin'",
26
26
  "paho-mqtt>=1.6.1,<3.0.0",
27
27
  "construct>=2.10.57,<3",
28
+ "protobuf>=5,<7",
28
29
  "vacuum-map-parser-roborock",
29
30
  "pyrate-limiter>=4.0.0,<5",
30
31
  "aiomqtt>=2.5.0,<3",
@@ -32,7 +33,7 @@ dependencies = [
32
33
  ]
33
34
 
34
35
  [project.urls]
35
- Repository = "https://github.com/humbertogontijo/python-roborock"
36
+ Repository = "https://github.com/python-roborock/python-roborock"
36
37
  Documentation = "https://python-roborock.readthedocs.io/"
37
38
 
38
39
  [project.scripts]
@@ -97,9 +98,15 @@ major_tags= ["refactor"]
97
98
  lint.ignore = ["F403", "E741"]
98
99
  lint.select=["E", "F", "UP", "I"]
99
100
  line-length = 120
101
+ extend-exclude = ["roborock/map/proto/*_pb2.py"]
100
102
 
101
103
  [tool.ruff.lint.per-file-ignores]
102
104
  "*/__init__.py" = ["F401"]
105
+ "roborock/map/proto/*_pb2.py" = ["E501", "I001", "UP009"]
106
+
107
+ [[tool.mypy.overrides]]
108
+ module = ["roborock.map.proto.*"]
109
+ ignore_errors = true
103
110
 
104
111
  [tool.pytest.ini_options]
105
112
  asyncio_mode = "auto"
@@ -1,5 +1,3 @@
1
- from __future__ import annotations
2
-
3
1
  import asyncio
4
2
  import hashlib
5
3
  import json
@@ -1,5 +1,3 @@
1
- from __future__ import annotations
2
-
3
1
  import logging
4
2
  from collections import namedtuple
5
3
  from enum import Enum, IntEnum, StrEnum
@@ -17,7 +15,7 @@ class RoborockEnum(IntEnum):
17
15
  return super().name.lower()
18
16
 
19
17
  @classmethod
20
- def _missing_(cls: type[RoborockEnum], key) -> RoborockEnum:
18
+ def _missing_(cls: type[Self], key) -> Self:
21
19
  if hasattr(cls, "unknown"):
22
20
  warning = f"Missing {cls.__name__} code: {key} - defaulting to 'unknown'"
23
21
  if warning not in completed_warnings:
@@ -32,23 +30,23 @@ class RoborockEnum(IntEnum):
32
30
  return default_value
33
31
 
34
32
  @classmethod
35
- def as_dict(cls: type[RoborockEnum]):
33
+ def as_dict(cls: type[Self]):
36
34
  return {i.name: i.value for i in cls if i.name != "missing"}
37
35
 
38
36
  @classmethod
39
- def as_enum_dict(cls: type[RoborockEnum]):
37
+ def as_enum_dict(cls: type[Self]):
40
38
  return {i.value: i for i in cls if i.name != "missing"}
41
39
 
42
40
  @classmethod
43
- def values(cls: type[RoborockEnum]) -> list[int]:
41
+ def values(cls: type[Self]) -> list[int]:
44
42
  return list(cls.as_dict().values())
45
43
 
46
44
  @classmethod
47
- def keys(cls: type[RoborockEnum]) -> list[str]:
45
+ def keys(cls: type[Self]) -> list[str]:
48
46
  return list(cls.as_dict().keys())
49
47
 
50
48
  @classmethod
51
- def items(cls: type[RoborockEnum]):
49
+ def items(cls: type[Self]):
52
50
  return cls.as_dict().items()
53
51
 
54
52
 
@@ -1,3 +1,5 @@
1
+ from typing import Self
2
+
1
3
  from ..code_mappings import RoborockEnum
2
4
 
3
5
 
@@ -91,7 +93,7 @@ class RoborockStartType(RoborockEnum):
91
93
 
92
94
  class RoborockDssCodes(RoborockEnum):
93
95
  @classmethod
94
- def _missing_(cls: type[RoborockEnum], key) -> RoborockEnum:
96
+ def _missing_(cls: type[Self], key) -> Self:
95
97
  # If the calculated value is not provided, then it should be viewed as okay.
96
98
  # As the math will sometimes result in you getting numbers that don't matter.
97
99
  return cls.okay # type: ignore
@@ -1,8 +1,6 @@
1
- from __future__ import annotations
2
-
3
1
  from dataclasses import dataclass, field, fields
4
2
  from enum import IntEnum, StrEnum
5
- from typing import Any
3
+ from typing import Any, Self
6
4
 
7
5
  from roborock.data.code_mappings import RoborockProductNickname
8
6
  from roborock.data.containers import RoborockBase
@@ -566,7 +564,7 @@ class DeviceFeatures(RoborockBase):
566
564
  new_feature_info_str: str,
567
565
  feature_info: list[int],
568
566
  product_nickname: RoborockProductNickname | None,
569
- ) -> DeviceFeatures:
567
+ ) -> Self:
570
568
  """Creates a DeviceFeatures instance from raw feature flags.
571
569
  :param new_feature_info: A int from get_init_status (sometimes can be found in homedata, but it is not always)
572
570
  :param new_feature_info_str: A hex string from get_init_status or home_data.
@@ -251,7 +251,7 @@ async def create_device_manager(
251
251
  trait = b01.q10.create(channel)
252
252
  elif "sc" in model_part:
253
253
  # Q7 devices start with 'sc' in their model naming.
254
- trait = b01.q7.create(channel)
254
+ trait = b01.q7.create(product, device, channel)
255
255
  else:
256
256
  raise UnsupportedDeviceError(f"Device {device.name} has unsupported B01 model: {product.model}")
257
257
  case _:
@@ -1,7 +1,5 @@
1
1
  """Thin wrapper around the MQTT channel for Roborock B01 Q10 devices."""
2
2
 
3
- from __future__ import annotations
4
-
5
3
  import logging
6
4
  from collections.abc import AsyncGenerator
7
5
  from typing import Any
@@ -6,16 +6,24 @@ import asyncio
6
6
  import json
7
7
  import logging
8
8
  from collections.abc import Callable
9
- from typing import Any, TypeVar
9
+ from typing import TypeAlias, TypeVar
10
10
 
11
11
  from roborock.devices.transport.mqtt_channel import MqttChannel
12
12
  from roborock.exceptions import RoborockException
13
- from roborock.protocols.b01_q7_protocol import B01_VERSION, Q7RequestMessage, decode_rpc_response, encode_mqtt_payload
13
+ from roborock.protocols.b01_q7_protocol import (
14
+ B01_VERSION,
15
+ MapKey,
16
+ Q7RequestMessage,
17
+ decode_map_payload,
18
+ decode_rpc_response,
19
+ encode_mqtt_payload,
20
+ )
14
21
  from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol
15
22
 
16
23
  _LOGGER = logging.getLogger(__name__)
17
24
  _TIMEOUT = 10.0
18
25
  _T = TypeVar("_T")
26
+ DecodedB01Response: TypeAlias = dict[str, object] | str
19
27
 
20
28
 
21
29
  def _matches_map_response(response_message: RoborockMessage, *, version: bytes | None) -> bytes | None:
@@ -61,11 +69,11 @@ async def _send_command(
61
69
  async def send_decoded_command(
62
70
  mqtt_channel: MqttChannel,
63
71
  request_message: Q7RequestMessage,
64
- ) -> Any:
72
+ ) -> DecodedB01Response:
65
73
  """Send a command on the MQTT channel and get a decoded response."""
66
74
  _LOGGER.debug("Sending B01 MQTT command: %s", request_message)
67
75
 
68
- def find_response(response_message: RoborockMessage) -> Any | None:
76
+ def find_response(response_message: RoborockMessage) -> DecodedB01Response | None:
69
77
  """Handle incoming messages and resolve the future."""
70
78
  try:
71
79
  decoded_dps = decode_rpc_response(response_message)
@@ -126,18 +134,32 @@ async def send_decoded_command(
126
134
  raise
127
135
 
128
136
 
129
- async def send_map_command(mqtt_channel: MqttChannel, request_message: Q7RequestMessage) -> bytes:
130
- """Send map upload command and wait for MAP_RESPONSE payload bytes.
137
+ class MapRpcChannel:
138
+ """RPC channel for map-related commands on B01/Q7 devices."""
131
139
 
132
- This stays separate from ``send_decoded_command()`` because map uploads arrive as
133
- raw ``MAP_RESPONSE`` payload bytes instead of a decoded RPC ``data`` payload.
134
- """
140
+ def __init__(self, mqtt_channel: MqttChannel, map_key: MapKey) -> None:
141
+ self._mqtt_channel = mqtt_channel
142
+ self._map_key = map_key
135
143
 
136
- try:
137
- return await _send_command(
138
- mqtt_channel,
139
- request_message,
140
- response_matcher=lambda response_message: _matches_map_response(response_message, version=B01_VERSION),
141
- )
142
- except TimeoutError as ex:
143
- raise RoborockException(f"B01 map command timed out after {_TIMEOUT}s ({request_message})") from ex
144
+ async def send_map_command(self, request_message: Q7RequestMessage) -> bytes:
145
+ """Send a map upload command and return decoded SCMap bytes.
146
+
147
+ This publishes the request and waits for a matching ``MAP_RESPONSE`` message
148
+ with the correct protocol version. The raw ``MAP_RESPONSE`` payload bytes are
149
+ then decoded/inflated via :func:`decode_map_payload` using this channel's
150
+ ``map_key``, and the resulting SCMap bytes are returned.
151
+
152
+ The returned value is the decoded map data bytes suitable for passing to the
153
+ map parser library, not the raw MQTT ``MAP_RESPONSE`` payload bytes.
154
+ """
155
+
156
+ try:
157
+ raw_payload = await _send_command(
158
+ self._mqtt_channel,
159
+ request_message,
160
+ response_matcher=lambda response_message: _matches_map_response(response_message, version=B01_VERSION),
161
+ )
162
+ except TimeoutError as ex:
163
+ raise RoborockException(f"B01 map command timed out after {_TIMEOUT}s ({request_message})") from ex
164
+
165
+ return decode_map_payload(raw_payload, map_key=self._map_key)
@@ -1,10 +1,14 @@
1
1
  """Traits for Q7 B01 devices.
2
- Potentially other devices may fall into this category in the future."""
2
+
3
+ Potentially other devices may fall into this category in the future.
4
+ """
5
+
6
+ from __future__ import annotations
3
7
 
4
8
  from typing import Any
5
9
 
6
10
  from roborock import B01Props
7
- from roborock.data import Q7MapList, Q7MapListEntry
11
+ from roborock.data import HomeDataDevice, HomeDataProduct, Q7MapList, Q7MapListEntry
8
12
  from roborock.data.b01_q7.b01_q7_code_mappings import (
9
13
  CleanPathPreferenceMapping,
10
14
  CleanRepeatMapping,
@@ -14,27 +18,30 @@ from roborock.data.b01_q7.b01_q7_code_mappings import (
14
18
  SCWindMapping,
15
19
  WaterLevelMapping,
16
20
  )
17
- from roborock.devices.rpc.b01_q7_channel import send_decoded_command
21
+ from roborock.devices.rpc.b01_q7_channel import MapRpcChannel, send_decoded_command
18
22
  from roborock.devices.traits import Trait
19
23
  from roborock.devices.transport.mqtt_channel import MqttChannel
20
- from roborock.protocols.b01_q7_protocol import B01_Q7_DPS, CommandType, ParamsType, Q7RequestMessage
24
+ from roborock.exceptions import RoborockException
25
+ from roborock.protocols.b01_q7_protocol import B01_Q7_DPS, CommandType, ParamsType, Q7RequestMessage, create_map_key
21
26
  from roborock.roborock_message import RoborockB01Props
22
27
  from roborock.roborock_typing import RoborockB01Q7Methods
23
28
 
24
29
  from .clean_summary import CleanSummaryTrait
25
30
  from .map import MapTrait
31
+ from .map_content import MapContentTrait
26
32
 
27
33
  __all__ = [
28
34
  "Q7PropertiesApi",
29
35
  "CleanSummaryTrait",
30
36
  "MapTrait",
37
+ "MapContentTrait",
31
38
  "Q7MapList",
32
39
  "Q7MapListEntry",
33
40
  ]
34
41
 
35
42
 
36
43
  class Q7PropertiesApi(Trait):
37
- """API for interacting with B01 devices."""
44
+ """API for interacting with B01 Q7 devices."""
38
45
 
39
46
  clean_summary: CleanSummaryTrait
40
47
  """Trait for clean records / clean summary (Q7 `service.get_record_list`)."""
@@ -42,11 +49,27 @@ class Q7PropertiesApi(Trait):
42
49
  map: MapTrait
43
50
  """Trait for map list metadata + raw map payload retrieval."""
44
51
 
45
- def __init__(self, channel: MqttChannel) -> None:
46
- """Initialize the B01Props API."""
52
+ map_content: MapContentTrait
53
+ """Trait for fetching parsed current map content."""
54
+
55
+ def __init__(
56
+ self, channel: MqttChannel, map_rpc_channel: MapRpcChannel, device: HomeDataDevice, product: HomeDataProduct
57
+ ) -> None:
58
+ """Initialize the Q7 API."""
47
59
  self._channel = channel
60
+ self._map_rpc_channel = map_rpc_channel
61
+ self._device = device
62
+ self._product = product
63
+
64
+ if not device.sn or not product.model:
65
+ raise ValueError("B01 Q7 map content requires device serial number and product model metadata")
66
+
48
67
  self.clean_summary = CleanSummaryTrait(channel)
49
68
  self.map = MapTrait(channel)
69
+ self.map_content = MapContentTrait(
70
+ self._map_rpc_channel,
71
+ self.map,
72
+ )
50
73
 
51
74
  async def query_values(self, props: list[RoborockB01Props]) -> B01Props | None:
52
75
  """Query the device for the values of the given Q7 properties."""
@@ -151,6 +174,11 @@ class Q7PropertiesApi(Trait):
151
174
  )
152
175
 
153
176
 
154
- def create(channel: MqttChannel) -> Q7PropertiesApi:
155
- """Create traits for B01 devices."""
156
- return Q7PropertiesApi(channel)
177
+ def create(product: HomeDataProduct, device: HomeDataDevice, channel: MqttChannel) -> Q7PropertiesApi:
178
+ """Create traits for B01 Q7 devices."""
179
+ if device.sn is None or product.model is None:
180
+ raise RoborockException(
181
+ f"Device serial number and product model are required (sn:: {device.sn}, model: {product.model})"
182
+ )
183
+ map_rpc_channel = MapRpcChannel(channel, map_key=create_map_key(serial=device.sn, model=product.model))
184
+ return Q7PropertiesApi(channel, device=device, product=product, map_rpc_channel=map_rpc_channel)
@@ -4,8 +4,6 @@ For B01/Q7, the Roborock app uses `service.get_record_list` which returns totals
4
4
  and a `record_list` whose items contain a JSON string in `detail`.
5
5
  """
6
6
 
7
- from __future__ import annotations
8
-
9
7
  import logging
10
8
 
11
9
  from roborock import CleanRecordDetail, CleanRecordList, CleanRecordSummary
@@ -1,9 +1,7 @@
1
1
  """Map trait for B01 Q7 devices."""
2
2
 
3
- import asyncio
4
-
5
3
  from roborock.data import Q7MapList
6
- from roborock.devices.rpc.b01_q7_channel import send_decoded_command, send_map_command
4
+ from roborock.devices.rpc.b01_q7_channel import send_decoded_command
7
5
  from roborock.devices.traits import Trait
8
6
  from roborock.devices.transport.mqtt_channel import MqttChannel
9
7
  from roborock.exceptions import RoborockException
@@ -12,14 +10,15 @@ from roborock.roborock_typing import RoborockB01Q7Methods
12
10
 
13
11
 
14
12
  class MapTrait(Q7MapList, Trait):
15
- """Map retrieval + map metadata helpers for Q7 devices."""
13
+ """Map trait for B01/Q7 devices, responsible for fetching and caching map list metadata.
14
+
15
+ The MapContent is fetched from the MapContent trait, which relies on this trait to determine the
16
+ current map ID to fetch.
17
+ """
16
18
 
17
19
  def __init__(self, channel: MqttChannel) -> None:
18
20
  super().__init__()
19
21
  self._channel = channel
20
- # Map uploads are serialized per-device to avoid response cross-wiring.
21
- self._map_command_lock = asyncio.Lock()
22
- self._loaded = False
23
22
 
24
23
  async def refresh(self) -> None:
25
24
  """Refresh cached map list metadata from the device."""
@@ -36,24 +35,3 @@ class MapTrait(Q7MapList, Trait):
36
35
  raise RoborockException(f"Failed to decode map list response: {response!r}")
37
36
 
38
37
  self.map_list = parsed.map_list
39
- self._loaded = True
40
-
41
- async def _get_map_payload(self, *, map_id: int) -> bytes:
42
- """Fetch raw map payload bytes for the given map id."""
43
- request = Q7RequestMessage(
44
- dps=B01_Q7_DPS,
45
- command=RoborockB01Q7Methods.UPLOAD_BY_MAPID,
46
- params={"map_id": map_id},
47
- )
48
- async with self._map_command_lock:
49
- return await send_map_command(self._channel, request)
50
-
51
- async def get_current_map_payload(self) -> bytes:
52
- """Fetch raw map payload bytes for the currently selected map."""
53
- if not self._loaded:
54
- await self.refresh()
55
-
56
- map_id = self.current_map_id
57
- if map_id is None:
58
- raise RoborockException(f"Unable to determine map_id from map list response: {self!r}")
59
- return await self._get_map_payload(map_id=map_id)
@@ -0,0 +1,100 @@
1
+ """Trait for fetching parsed map content from B01/Q7 devices.
2
+
3
+ This intentionally mirrors the v1 `MapContentTrait` contract:
4
+ - `refresh()` performs I/O and populates cached fields
5
+ - `parse_map_content()` reparses cached raw bytes without I/O
6
+ - fields `image_content`, `map_data`, and `raw_api_response` are then readable
7
+
8
+ For B01/Q7 devices, the underlying raw map payload is retrieved via `MapTrait`.
9
+ """
10
+
11
+ import asyncio
12
+ from dataclasses import dataclass
13
+
14
+ from vacuum_map_parser_base.map_data import MapData
15
+
16
+ from roborock.data import RoborockBase
17
+ from roborock.devices.rpc.b01_q7_channel import MapRpcChannel
18
+ from roborock.devices.traits import Trait
19
+ from roborock.exceptions import RoborockException
20
+ from roborock.map.b01_map_parser import B01MapParser, B01MapParserConfig
21
+ from roborock.protocols.b01_q7_protocol import B01_Q7_DPS, Q7RequestMessage
22
+ from roborock.roborock_typing import RoborockB01Q7Methods
23
+
24
+ from .map import MapTrait
25
+
26
+ _TRUNCATE_LENGTH = 20
27
+
28
+
29
+ @dataclass
30
+ class MapContent(RoborockBase):
31
+ """Dataclass representing map content."""
32
+
33
+ image_content: bytes | None = None
34
+ """The rendered image of the map in PNG format."""
35
+
36
+ map_data: MapData | None = None
37
+ """Parsed map data (metadata for points on the map)."""
38
+
39
+ raw_api_response: bytes | None = None
40
+ """Raw bytes of the map payload from the device.
41
+
42
+ This should be treated as an opaque blob used only internally by this
43
+ library to re-parse the map data when needed.
44
+ """
45
+
46
+ def __repr__(self) -> str:
47
+ img = self.image_content
48
+ if img and len(img) > _TRUNCATE_LENGTH:
49
+ img = img[: _TRUNCATE_LENGTH - 3] + b"..."
50
+ return f"MapContent(image_content={img!r}, map_data={self.map_data!r})"
51
+
52
+
53
+ class MapContentTrait(MapContent, Trait):
54
+ """Trait for fetching parsed map content for Q7 devices."""
55
+
56
+ def __init__(
57
+ self,
58
+ map_rpc_channel: MapRpcChannel,
59
+ map_trait: MapTrait,
60
+ *,
61
+ map_parser_config: B01MapParserConfig | None = None,
62
+ ) -> None:
63
+ super().__init__()
64
+ self._map_rpc_channel = map_rpc_channel
65
+ self._map_trait = map_trait
66
+ self._map_parser = B01MapParser(map_parser_config)
67
+ # Map uploads are serialized per-device to avoid response cross-wiring.
68
+ self._map_command_lock = asyncio.Lock()
69
+
70
+ async def refresh(self) -> None:
71
+ """Fetch, decode, and parse the current map payload.
72
+
73
+ This relies on the Map Trait already having fetched the map list metadata
74
+ so it can determine the current map_id.
75
+ """
76
+ # Users must call first
77
+ if (map_id := self._map_trait.current_map_id) is None:
78
+ raise RoborockException("Unable to determine current map ID")
79
+
80
+ request = Q7RequestMessage(
81
+ dps=B01_Q7_DPS,
82
+ command=RoborockB01Q7Methods.UPLOAD_BY_MAPID,
83
+ params={"map_id": map_id},
84
+ )
85
+ async with self._map_command_lock:
86
+ raw_payload = await self._map_rpc_channel.send_map_command(request)
87
+
88
+ try:
89
+ parsed_data = self._map_parser.parse(raw_payload)
90
+ except RoborockException:
91
+ raise
92
+ except Exception as ex:
93
+ raise RoborockException("Failed to parse B01 map data") from ex
94
+
95
+ if parsed_data.image_content is None:
96
+ raise RoborockException("Failed to render B01 map image")
97
+
98
+ self.image_content = parsed_data.image_content
99
+ self.map_data = parsed_data.map_data
100
+ self.raw_api_response = raw_payload
@@ -1,7 +1,5 @@
1
1
  """Trait for device network information."""
2
2
 
3
- from __future__ import annotations
4
-
5
3
  import logging
6
4
 
7
5
  from roborock.data import NetworkInfo
@@ -9,13 +9,11 @@ data is collected and exposed to clients via higher level APIs like the
9
9
  DeviceManager.
10
10
  """
11
11
 
12
- from __future__ import annotations
13
-
14
12
  import time
15
13
  from collections import Counter
16
14
  from collections.abc import Generator, Mapping
17
15
  from contextlib import contextmanager
18
- from typing import Any, TypeVar, cast
16
+ from typing import Any, Self, TypeVar, cast
19
17
 
20
18
 
21
19
  class Diagnostics:
@@ -28,7 +26,7 @@ class Diagnostics:
28
26
  def __init__(self) -> None:
29
27
  """Initialize Diagnostics."""
30
28
  self._counter: Counter = Counter()
31
- self._subkeys: dict[str, Diagnostics] = {}
29
+ self._subkeys: dict[str, Self] = {}
32
30
 
33
31
  def increment(self, key: str, count: int = 1) -> None:
34
32
  """Increment a counter for the specified key/event."""
@@ -49,7 +47,7 @@ class Diagnostics:
49
47
  data[k] = v
50
48
  return data
51
49
 
52
- def subkey(self, key: str) -> Diagnostics:
50
+ def subkey(self, key: str) -> Self:
53
51
  """Return sub-Diagnostics object with the specified subkey.
54
52
 
55
53
  This will create a new Diagnostics object if one does not already exist
@@ -63,7 +61,7 @@ class Diagnostics:
63
61
  The Diagnostics object for the specified subkey.
64
62
  """
65
63
  if key not in self._subkeys:
66
- self._subkeys[key] = Diagnostics()
64
+ self._subkeys[key] = type(self)()
67
65
  return self._subkeys[key]
68
66
 
69
67
  @contextmanager
@@ -1,7 +1,5 @@
1
1
  """Roborock exceptions."""
2
2
 
3
- from __future__ import annotations
4
-
5
3
 
6
4
  class RoborockException(Exception):
7
5
  """Class for Roborock exceptions."""