python-roborock 5.1.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.
- {python_roborock-5.1.0 → python_roborock-5.2.0}/PKG-INFO +1 -1
- {python_roborock-5.1.0 → python_roborock-5.2.0}/pyproject.toml +1 -1
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/rpc/b01_q7_channel.py +35 -14
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/b01/q7/__init__.py +14 -6
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/b01/q7/map.py +6 -28
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/b01/q7/map_content.py +28 -26
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/map/b01_map_parser.py +8 -62
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/protocols/b01_q7_protocol.py +50 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/.gitignore +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/LICENSE +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/README.md +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/__init__.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/broadcast_protocol.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/callbacks.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/cli.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/const.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/data/__init__.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/data/b01_q10/__init__.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/data/b01_q7/__init__.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/data/code_mappings.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/data/containers.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/data/dyad/__init__.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/data/dyad/dyad_code_mappings.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/data/dyad/dyad_containers.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/data/v1/__init__.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/data/v1/v1_clean_modes.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/data/v1/v1_code_mappings.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/data/v1/v1_containers.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/data/zeo/__init__.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/data/zeo/zeo_code_mappings.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/data/zeo/zeo_containers.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/device_features.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/README.md +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/__init__.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/cache.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/device.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/device_manager.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/file_cache.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/rpc/__init__.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/rpc/a01_channel.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/rpc/b01_q10_channel.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/rpc/v1_channel.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/__init__.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/a01/__init__.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/b01/__init__.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/b01/q10/__init__.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/b01/q10/command.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/b01/q10/common.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/b01/q10/status.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/traits_mixin.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/__init__.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/child_lock.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/clean_summary.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/command.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/common.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/consumeable.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/device_features.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/flow_led_status.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/home.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/led_status.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/map_content.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/maps.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/network_info.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/rooms.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/routines.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/status.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/volume.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/transport/__init__.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/transport/channel.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/transport/local_channel.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/transport/mqtt_channel.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/diagnostics.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/exceptions.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/map/__init__.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/map/map_parser.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/map/proto/__init__.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/map/proto/b01_scmap.proto +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/map/proto/b01_scmap_pb2.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/mqtt/__init__.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/mqtt/health_manager.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/mqtt/roborock_session.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/mqtt/session.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/protocol.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/protocols/__init__.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/protocols/a01_protocol.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/protocols/b01_q10_protocol.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/protocols/v1_protocol.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/py.typed +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/roborock_message.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/roborock_typing.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/util.py +0 -0
- {python_roborock-5.1.0 → python_roborock-5.2.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.
|
|
3
|
+
Version: 5.2.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.
|
|
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"
|
|
@@ -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
|
|
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
|
-
|
|
131
|
-
"""
|
|
137
|
+
class MapRpcChannel:
|
|
138
|
+
"""RPC channel for map-related commands on B01/Q7 devices."""
|
|
132
139
|
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
138
|
-
return
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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.
|
|
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__(
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
{python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/b01/q7/map_content.py
RENAMED
|
@@ -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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
@@ -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
|
|
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,
|
|
50
|
-
"""Parse
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/data/b01_q10/b01_q10_code_mappings.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/data/b01_q7/b01_q7_code_mappings.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/b01/q7/clean_summary.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/device_features.py
RENAMED
|
File without changes
|
{python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/do_not_disturb.py
RENAMED
|
File without changes
|
{python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/dust_collection_mode.py
RENAMED
|
File without changes
|
{python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/flow_led_status.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/smart_wash_params.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_roborock-5.1.0 → python_roborock-5.2.0}/roborock/devices/traits/v1/wash_towel_mode.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|