python-roborock 5.1.0__tar.gz → 5.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 (103) hide show
  1. {python_roborock-5.1.0 → python_roborock-5.3.0}/PKG-INFO +1 -1
  2. {python_roborock-5.1.0 → python_roborock-5.3.0}/pyproject.toml +1 -1
  3. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/rpc/b01_q7_channel.py +35 -14
  4. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/b01/q7/__init__.py +14 -6
  5. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/b01/q7/map.py +6 -28
  6. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/b01/q7/map_content.py +28 -26
  7. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/v1/consumeable.py +2 -0
  8. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/map/b01_map_parser.py +8 -62
  9. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/protocols/b01_q7_protocol.py +50 -0
  10. {python_roborock-5.1.0 → python_roborock-5.3.0}/.gitignore +0 -0
  11. {python_roborock-5.1.0 → python_roborock-5.3.0}/LICENSE +0 -0
  12. {python_roborock-5.1.0 → python_roborock-5.3.0}/README.md +0 -0
  13. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/__init__.py +0 -0
  14. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/broadcast_protocol.py +0 -0
  15. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/callbacks.py +0 -0
  16. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/cli.py +0 -0
  17. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/const.py +0 -0
  18. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/data/__init__.py +0 -0
  19. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/data/b01_q10/__init__.py +0 -0
  20. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
  21. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
  22. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/data/b01_q7/__init__.py +0 -0
  23. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  24. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  25. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/data/code_mappings.py +0 -0
  26. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/data/containers.py +0 -0
  27. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/data/dyad/__init__.py +0 -0
  28. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  29. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/data/dyad/dyad_containers.py +0 -0
  30. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/data/v1/__init__.py +0 -0
  31. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/data/v1/v1_clean_modes.py +0 -0
  32. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/data/v1/v1_code_mappings.py +0 -0
  33. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/data/v1/v1_containers.py +0 -0
  34. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/data/zeo/__init__.py +0 -0
  35. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  36. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/data/zeo/zeo_containers.py +0 -0
  37. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/device_features.py +0 -0
  38. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/README.md +0 -0
  39. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/__init__.py +0 -0
  40. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/cache.py +0 -0
  41. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/device.py +0 -0
  42. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/device_manager.py +0 -0
  43. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/file_cache.py +0 -0
  44. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/rpc/__init__.py +0 -0
  45. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/rpc/a01_channel.py +0 -0
  46. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/rpc/b01_q10_channel.py +0 -0
  47. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/rpc/v1_channel.py +0 -0
  48. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/__init__.py +0 -0
  49. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/a01/__init__.py +0 -0
  50. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/b01/__init__.py +0 -0
  51. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/b01/q10/__init__.py +0 -0
  52. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/b01/q10/command.py +0 -0
  53. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/b01/q10/common.py +0 -0
  54. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/b01/q10/status.py +0 -0
  55. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
  56. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
  57. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/traits_mixin.py +0 -0
  58. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/v1/__init__.py +0 -0
  59. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/v1/child_lock.py +0 -0
  60. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/v1/clean_summary.py +0 -0
  61. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/v1/command.py +0 -0
  62. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/v1/common.py +0 -0
  63. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/v1/device_features.py +0 -0
  64. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  65. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  66. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  67. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/v1/home.py +0 -0
  68. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/v1/led_status.py +0 -0
  69. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/v1/map_content.py +0 -0
  70. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/v1/maps.py +0 -0
  71. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/v1/network_info.py +0 -0
  72. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/v1/rooms.py +0 -0
  73. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/v1/routines.py +0 -0
  74. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  75. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/v1/status.py +0 -0
  76. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  77. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/v1/volume.py +0 -0
  78. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  79. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/transport/__init__.py +0 -0
  80. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/transport/channel.py +0 -0
  81. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/transport/local_channel.py +0 -0
  82. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/devices/transport/mqtt_channel.py +0 -0
  83. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/diagnostics.py +0 -0
  84. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/exceptions.py +0 -0
  85. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/map/__init__.py +0 -0
  86. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/map/map_parser.py +0 -0
  87. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/map/proto/__init__.py +0 -0
  88. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/map/proto/b01_scmap.proto +0 -0
  89. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/map/proto/b01_scmap_pb2.py +0 -0
  90. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/mqtt/__init__.py +0 -0
  91. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/mqtt/health_manager.py +0 -0
  92. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/mqtt/roborock_session.py +0 -0
  93. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/mqtt/session.py +0 -0
  94. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/protocol.py +0 -0
  95. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/protocols/__init__.py +0 -0
  96. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/protocols/a01_protocol.py +0 -0
  97. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/protocols/b01_q10_protocol.py +0 -0
  98. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/protocols/v1_protocol.py +0 -0
  99. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/py.typed +0 -0
  100. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/roborock_message.py +0 -0
  101. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/roborock_typing.py +0 -0
  102. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/util.py +0 -0
  103. {python_roborock-5.1.0 → python_roborock-5.3.0}/roborock/web_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-roborock
3
- Version: 5.1.0
3
+ Version: 5.3.0
4
4
  Summary: A package to control Roborock vacuums.
5
5
  Project-URL: Repository, https://github.com/python-roborock/python-roborock
6
6
  Project-URL: Documentation, https://python-roborock.readthedocs.io/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-roborock"
3
- version = "5.1.0"
3
+ version = "5.3.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"
@@ -10,7 +10,14 @@ 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__)
@@ -127,18 +134,32 @@ async def send_decoded_command(
127
134
  raise
128
135
 
129
136
 
130
- async def send_map_command(mqtt_channel: MqttChannel, request_message: Q7RequestMessage) -> bytes:
131
- """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."""
132
139
 
133
- This stays separate from ``send_decoded_command()`` because map uploads arrive as
134
- raw ``MAP_RESPONSE`` payload bytes instead of a decoded RPC ``data`` payload.
135
- """
140
+ def __init__(self, mqtt_channel: MqttChannel, map_key: MapKey) -> None:
141
+ self._mqtt_channel = mqtt_channel
142
+ self._map_key = map_key
136
143
 
137
- try:
138
- return await _send_command(
139
- mqtt_channel,
140
- request_message,
141
- response_matcher=lambda response_message: _matches_map_response(response_message, version=B01_VERSION),
142
- )
143
- except TimeoutError as ex:
144
- 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)
@@ -18,10 +18,11 @@ from roborock.data.b01_q7.b01_q7_code_mappings import (
18
18
  SCWindMapping,
19
19
  WaterLevelMapping,
20
20
  )
21
- from roborock.devices.rpc.b01_q7_channel import send_decoded_command
21
+ from roborock.devices.rpc.b01_q7_channel import MapRpcChannel, send_decoded_command
22
22
  from roborock.devices.traits import Trait
23
23
  from roborock.devices.transport.mqtt_channel import MqttChannel
24
- 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
25
26
  from roborock.roborock_message import RoborockB01Props
26
27
  from roborock.roborock_typing import RoborockB01Q7Methods
27
28
 
@@ -51,9 +52,12 @@ class Q7PropertiesApi(Trait):
51
52
  map_content: MapContentTrait
52
53
  """Trait for fetching parsed current map content."""
53
54
 
54
- def __init__(self, channel: MqttChannel, *, device: HomeDataDevice, product: HomeDataProduct) -> None:
55
+ def __init__(
56
+ self, channel: MqttChannel, map_rpc_channel: MapRpcChannel, device: HomeDataDevice, product: HomeDataProduct
57
+ ) -> None:
55
58
  """Initialize the Q7 API."""
56
59
  self._channel = channel
60
+ self._map_rpc_channel = map_rpc_channel
57
61
  self._device = device
58
62
  self._product = product
59
63
 
@@ -63,9 +67,8 @@ class Q7PropertiesApi(Trait):
63
67
  self.clean_summary = CleanSummaryTrait(channel)
64
68
  self.map = MapTrait(channel)
65
69
  self.map_content = MapContentTrait(
70
+ self._map_rpc_channel,
66
71
  self.map,
67
- serial=device.sn,
68
- model=product.model,
69
72
  )
70
73
 
71
74
  async def query_values(self, props: list[RoborockB01Props]) -> B01Props | None:
@@ -173,4 +176,9 @@ class Q7PropertiesApi(Trait):
173
176
 
174
177
  def create(product: HomeDataProduct, device: HomeDataDevice, channel: MqttChannel) -> Q7PropertiesApi:
175
178
  """Create traits for B01 Q7 devices."""
176
- return Q7PropertiesApi(channel, device=device, product=product)
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)
@@ -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)
@@ -8,14 +8,18 @@ This intentionally mirrors the v1 `MapContentTrait` contract:
8
8
  For B01/Q7 devices, the underlying raw map payload is retrieved via `MapTrait`.
9
9
  """
10
10
 
11
+ import asyncio
11
12
  from dataclasses import dataclass
12
13
 
13
14
  from vacuum_map_parser_base.map_data import MapData
14
15
 
15
16
  from roborock.data import RoborockBase
17
+ from roborock.devices.rpc.b01_q7_channel import MapRpcChannel
16
18
  from roborock.devices.traits import Trait
17
19
  from roborock.exceptions import RoborockException
18
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
19
23
 
20
24
  from .map import MapTrait
21
25
 
@@ -51,38 +55,38 @@ class MapContentTrait(MapContent, Trait):
51
55
 
52
56
  def __init__(
53
57
  self,
58
+ map_rpc_channel: MapRpcChannel,
54
59
  map_trait: MapTrait,
55
60
  *,
56
- serial: str,
57
- model: str,
58
61
  map_parser_config: B01MapParserConfig | None = None,
59
62
  ) -> None:
60
63
  super().__init__()
64
+ self._map_rpc_channel = map_rpc_channel
61
65
  self._map_trait = map_trait
62
- self._serial = serial
63
- self._model = model
64
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()
65
69
 
66
70
  async def refresh(self) -> None:
67
- """Fetch, decode, and parse the current map payload."""
68
- raw_payload = await self._map_trait.get_current_map_payload()
69
- parsed = self.parse_map_content(raw_payload)
70
- self.image_content = parsed.image_content
71
- self.map_data = parsed.map_data
72
- self.raw_api_response = parsed.raw_api_response
73
-
74
- def parse_map_content(self, response: bytes) -> MapContent:
75
- """Parse map content from raw bytes.
76
-
77
- This mirrors the v1 trait behavior so cached map payload bytes can be
78
- reparsed without going back to the device.
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.
79
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
+
80
88
  try:
81
- parsed_data = self._map_parser.parse(
82
- response,
83
- serial=self._serial,
84
- model=self._model,
85
- )
89
+ parsed_data = self._map_parser.parse(raw_payload)
86
90
  except RoborockException:
87
91
  raise
88
92
  except Exception as ex:
@@ -91,8 +95,6 @@ class MapContentTrait(MapContent, Trait):
91
95
  if parsed_data.image_content is None:
92
96
  raise RoborockException("Failed to render B01 map image")
93
97
 
94
- return MapContent(
95
- image_content=parsed_data.image_content,
96
- map_data=parsed_data.map_data,
97
- raw_api_response=response,
98
- )
98
+ self.image_content = parsed_data.image_content
99
+ self.map_data = parsed_data.map_data
100
+ self.raw_api_response = raw_payload
@@ -23,6 +23,8 @@ class ConsumableAttribute(StrEnum):
23
23
  FILTER_WORK_TIME = "filter_work_time"
24
24
  SIDE_BRUSH_WORK_TIME = "side_brush_work_time"
25
25
  MAIN_BRUSH_WORK_TIME = "main_brush_work_time"
26
+ STRAINER_WORK_TIME = "strainer_work_time"
27
+ CLEANING_BRUSH_WORK_TIME = "cleaning_brush_work_time"
26
28
 
27
29
  @classmethod
28
30
  def from_str(cls, value: str) -> Self:
@@ -1,31 +1,19 @@
1
1
  """Module for parsing B01/Q7 map content.
2
2
 
3
- Observed Q7 `MAP_RESPONSE` payloads follow this decode pipeline:
4
- - base64-encoded ASCII
5
- - AES-ECB encrypted with the derived map key
6
- - PKCS7 padded
7
- - ASCII hex for a zlib-compressed SCMap payload
8
-
9
3
  The inner SCMap blob is parsed with protobuf messages generated from
10
4
  `roborock/map/proto/b01_scmap.proto`.
11
5
  """
12
6
 
13
- import base64
14
- import binascii
15
- import hashlib
16
7
  import io
17
- import zlib
18
8
  from dataclasses import dataclass
19
9
 
20
- from Crypto.Cipher import AES
21
- from google.protobuf.message import DecodeError, Message
10
+ from google.protobuf.message import DecodeError
22
11
  from PIL import Image
23
12
  from vacuum_map_parser_base.config.image_config import ImageConfig
24
13
  from vacuum_map_parser_base.map_data import ImageData, MapData
25
14
 
26
15
  from roborock.exceptions import RoborockException
27
16
  from roborock.map.proto.b01_scmap_pb2 import RobotMap # type: ignore[attr-defined]
28
- from roborock.protocol import Utils
29
17
 
30
18
  from .map_parser import ParsedMapData
31
19
 
@@ -46,10 +34,9 @@ class B01MapParser:
46
34
  def __init__(self, config: B01MapParserConfig | None = None) -> None:
47
35
  self._config = config or B01MapParserConfig()
48
36
 
49
- def parse(self, raw_payload: bytes, *, serial: str, model: str) -> ParsedMapData:
50
- """Parse a raw MAP_RESPONSE payload and return a PNG + MapData."""
51
- inflated = _decode_b01_map_payload(raw_payload, serial=serial, model=model)
52
- parsed = _parse_scmap_payload(inflated)
37
+ def parse(self, payload: bytes) -> ParsedMapData:
38
+ """Parse an inflated SCMap payload and return a PNG + MapData."""
39
+ parsed = _parse_scmap_payload(payload)
53
40
  size_x, size_y, grid = _extract_grid(parsed)
54
41
  room_names = _extract_room_names(parsed)
55
42
 
@@ -78,54 +65,13 @@ class B01MapParser:
78
65
  )
79
66
 
80
67
 
81
- def _derive_map_key(serial: str, model: str) -> bytes:
82
- """Derive the B01/Q7 map decrypt key from serial + model."""
83
- model_suffix = model.split(".")[-1]
84
- model_key = (model_suffix + "0" * 16)[:16].encode()
85
- material = f"{serial}+{model_suffix}+{serial}".encode()
86
- encrypted = Utils.encrypt_ecb(material, model_key)
87
- md5 = hashlib.md5(base64.b64encode(encrypted), usedforsecurity=False).hexdigest()
88
- return md5[8:24].encode()
89
-
90
-
91
- def _decode_base64_payload(raw_payload: bytes) -> bytes:
92
- blob = raw_payload.strip()
93
- padded = blob + b"=" * (-len(blob) % 4)
94
- try:
95
- return base64.b64decode(padded, validate=True)
96
- except binascii.Error as err:
97
- raise RoborockException("Failed to decode B01 map payload") from err
98
-
99
-
100
- def _decode_b01_map_payload(raw_payload: bytes, *, serial: str, model: str) -> bytes:
101
- """Decode raw B01 `MAP_RESPONSE` payload into inflated SCMap bytes."""
102
- # TODO: Move this lower-level B01 transport decode under `roborock.protocols`
103
- # so this module only handles SCMap parsing/rendering.
104
- encrypted_payload = _decode_base64_payload(raw_payload)
105
- if len(encrypted_payload) % AES.block_size != 0:
106
- raise RoborockException("Unexpected encrypted B01 map payload length")
107
-
108
- map_key = _derive_map_key(serial, model)
109
-
110
- try:
111
- compressed_hex = Utils.decrypt_ecb(encrypted_payload, map_key).decode("ascii")
112
- compressed_payload = bytes.fromhex(compressed_hex)
113
- return zlib.decompress(compressed_payload)
114
- except (ValueError, UnicodeDecodeError, zlib.error) as err:
115
- raise RoborockException("Failed to decode B01 map payload") from err
116
-
117
-
118
- def _parse_proto(blob: bytes, message: Message, *, context: str) -> None:
119
- try:
120
- message.ParseFromString(blob)
121
- except DecodeError as err:
122
- raise RoborockException(f"Failed to parse {context}") from err
123
-
124
-
125
68
  def _parse_scmap_payload(payload: bytes) -> RobotMap:
126
69
  """Parse inflated SCMap bytes into a generated protobuf message."""
127
70
  parsed = RobotMap()
128
- _parse_proto(payload, parsed, context="B01 SCMap")
71
+ try:
72
+ parsed.ParseFromString(payload)
73
+ except DecodeError as err:
74
+ raise RoborockException("Failed to parse B01 SCMap") from err
129
75
  return parsed
130
76
 
131
77
 
@@ -1,7 +1,11 @@
1
1
  """Roborock B01 Protocol encoding and decoding."""
2
2
 
3
+ import base64
4
+ import binascii
5
+ import hashlib
3
6
  import json
4
7
  import logging
8
+ import zlib
5
9
  from dataclasses import dataclass, field
6
10
  from typing import Any
7
11
 
@@ -10,6 +14,7 @@ from Crypto.Util.Padding import pad, unpad
10
14
 
11
15
  from roborock import RoborockB01Q7Methods
12
16
  from roborock.exceptions import RoborockException
17
+ from roborock.protocol import Utils
13
18
  from roborock.roborock_message import (
14
19
  RoborockMessage,
15
20
  RoborockMessageProtocol,
@@ -80,3 +85,48 @@ def decode_rpc_response(message: RoborockMessage) -> dict[int, Any]:
80
85
  return {int(key): value for key, value in datapoints.items()}
81
86
  except ValueError:
82
87
  raise RoborockException(f"Invalid B01 message format: 'dps' key should be an integer for {message.payload!r}")
88
+
89
+
90
+ @dataclass
91
+ class MapKey:
92
+ """Data class for holding a B01 map decryption key."""
93
+
94
+ key: bytes
95
+
96
+
97
+ def create_map_key(serial: str, model: str) -> MapKey:
98
+ """Derive the B01/Q7 map decrypt key from serial + model."""
99
+ model_suffix = model.split(".")[-1]
100
+ model_key = (model_suffix + "0" * 16)[:16].encode()
101
+ material = f"{serial}+{model_suffix}+{serial}".encode()
102
+ encrypted = Utils.encrypt_ecb(material, model_key)
103
+ md5 = hashlib.md5(base64.b64encode(encrypted), usedforsecurity=False).hexdigest()
104
+ return MapKey(key=md5[8:24].encode())
105
+
106
+
107
+ def decode_map_payload(raw_payload: bytes, map_key: MapKey) -> bytes:
108
+ """Decode raw B01 `MAP_RESPONSE` payload into inflated SCMap bytes."""
109
+ encrypted_payload = _decode_base64_payload(raw_payload)
110
+ payload_len = len(encrypted_payload)
111
+ if payload_len % AES.block_size != 0:
112
+ raise RoborockException(
113
+ f"Unexpected encrypted B01 map payload length: {payload_len} (not a multiple of AES block size)"
114
+ )
115
+
116
+ try:
117
+ compressed_hex = Utils.decrypt_ecb(encrypted_payload, token=map_key.key).decode("ascii")
118
+ compressed_payload = bytes.fromhex(compressed_hex)
119
+ return zlib.decompress(compressed_payload)
120
+ except (ValueError, UnicodeDecodeError, zlib.error) as err:
121
+ raise RoborockException("Failed to decode B01 map payload") from err
122
+
123
+
124
+ def _decode_base64_payload(raw_payload: bytes) -> bytes:
125
+ """Decode base64 payload."""
126
+
127
+ blob = raw_payload.strip()
128
+ padded = blob + b"=" * (-len(blob) % 4)
129
+ try:
130
+ return base64.b64decode(padded, validate=True)
131
+ except binascii.Error as err:
132
+ raise RoborockException("Failed to decode B01 map payload") from err
File without changes