python-roborock 7.15.1__tar.gz → 7.15.2__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-7.15.1 → python_roborock-7.15.2}/PKG-INFO +1 -1
- {python_roborock-7.15.1 → python_roborock-7.15.2}/pyproject.toml +1 -1
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/data/mower/mower_code_mappings.py +4 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/data/mower/mower_containers.py +1 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/map/b01_q10_map_parser.py +0 -118
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/map/b01_q10_render.py +92 -6
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/testing/v1_simulator.py +44 -5
- python_roborock-7.15.2/roborock/web_api/__init__.py +37 -0
- python_roborock-7.15.2/roborock/web_api/_base.py +168 -0
- python_roborock-7.15.2/roborock/web_api/_hawk.py +58 -0
- python_roborock-7.15.2/roborock/web_api/_request.py +44 -0
- python_roborock-7.15.2/roborock/web_api/auth.py +278 -0
- python_roborock-7.15.2/roborock/web_api/client.py +80 -0
- python_roborock-7.15.2/roborock/web_api/devices.py +84 -0
- python_roborock-7.15.2/roborock/web_api/homes.py +81 -0
- python_roborock-7.15.2/roborock/web_api/jobs.py +50 -0
- python_roborock-7.15.2/roborock/web_api/ota.py +44 -0
- python_roborock-7.15.2/roborock/web_api/products.py +49 -0
- python_roborock-7.15.2/roborock/web_api/scenes.py +28 -0
- python_roborock-7.15.1/roborock/web_api.py +0 -977
- {python_roborock-7.15.1 → python_roborock-7.15.2}/.gitignore +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/LICENSE +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/README.md +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/__init__.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/broadcast_protocol.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/callbacks.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/cli.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/const.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/data/__init__.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/data/b01_q10/__init__.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/data/b01_q7/__init__.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/data/camera.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/data/code_mappings.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/data/containers.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/data/dyad/__init__.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/data/dyad/dyad_code_mappings.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/data/dyad/dyad_containers.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/data/mower/__init__.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/data/v1/__init__.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/data/v1/v1_clean_modes.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/data/v1/v1_code_mappings.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/data/v1/v1_containers.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/data/v1/v1_dynamic_map.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/data/zeo/__init__.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/data/zeo/zeo_code_mappings.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/data/zeo/zeo_containers.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/device_features.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/README.md +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/__init__.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/cache.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/device.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/device_manager.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/file_cache.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/rpc/__init__.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/rpc/a01_channel.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/rpc/b01_q10_channel.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/rpc/b01_q7_channel.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/rpc/v1_channel.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/__init__.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/a01/__init__.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/a01/device_feature.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/b01/__init__.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/__init__.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/button_light.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/child_lock.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/clean_history.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/command.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/common.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/consumable.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/do_not_disturb.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/dust_collection.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/goto.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/map.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/maps.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/network_info.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/remote.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/status.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/volume.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/b01/q7/__init__.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/b01/q7/map.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/b01/q7/map_content.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/common.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/traits_mixin.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/v1/__init__.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/v1/child_lock.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/v1/clean_summary.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/v1/command.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/v1/common.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/v1/consumeable.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/v1/device_features.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/v1/flow_led_status.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/v1/home.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/v1/led_status.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/v1/map_content.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/v1/maps.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/v1/mop_dryer.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/v1/network_info.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/v1/obstacle_photos.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/v1/rooms.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/v1/routines.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/v1/status.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/v1/volume.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/transport/__init__.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/transport/channel.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/transport/local_channel.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/devices/transport/mqtt_channel.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/diagnostics.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/exceptions.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/map/__init__.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/map/b01_grid_layers.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/map/b01_map_parser.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/map/b01_q10_overlays.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/map/dynamic_blocks.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/map/map_parser.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/map/proto/__init__.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/map/proto/b01_scmap.proto +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/map/proto/b01_scmap_pb2.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/map/room_colors.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/mqtt/__init__.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/mqtt/health_manager.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/mqtt/roborock_session.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/mqtt/session.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/protocol.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/protocols/__init__.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/protocols/a01_protocol.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/protocols/b01_q10_protocol.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/protocols/b01_q7_protocol.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/protocols/camera_signaling.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/protocols/v1_protocol.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/py.typed +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/roborock_message.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/roborock_typing.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/testing/__init__.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/testing/a01_simulator.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/testing/b01_q10_simulator.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/testing/channel.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/testing/cloud.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/testing/simulator.py +0 -0
- {python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/util.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: python-roborock
|
|
3
|
-
Version: 7.15.
|
|
3
|
+
Version: 7.15.2
|
|
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 = "7.15.
|
|
3
|
+
version = "7.15.2"
|
|
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"
|
{python_roborock-7.15.1 → python_roborock-7.15.2}/roborock/data/mower/mower_code_mappings.py
RENAMED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
"""Code mappings for Roborock mower devices."""
|
|
2
2
|
|
|
3
|
+
import logging
|
|
3
4
|
from typing import Self, cast
|
|
4
5
|
|
|
5
6
|
from roborock.data.code_mappings import RoborockModeEnum
|
|
6
7
|
|
|
8
|
+
_LOGGER = logging.getLogger(__name__)
|
|
9
|
+
|
|
7
10
|
__all__ = [
|
|
8
11
|
"MowerAfsStatus",
|
|
9
12
|
"MowerChargeState",
|
|
@@ -38,6 +41,7 @@ class MowerEnum(RoborockModeEnum):
|
|
|
38
41
|
"""Resolve a wire value by name, string value, or integer code."""
|
|
39
42
|
if (member := cls.from_any_optional(code)) is not None:
|
|
40
43
|
return member
|
|
44
|
+
_LOGGER.debug("Unknown %s code %r; defaulting to UNKNOWN", cls.__name__, code)
|
|
41
45
|
return cast(Self, cls.UNKNOWN) # type: ignore[attr-defined]
|
|
42
46
|
|
|
43
47
|
|
|
@@ -48,6 +48,7 @@ class MowerStatus(RoborockBase):
|
|
|
48
48
|
"""Core mower status backed by mower DPS updates (data points 120-145)."""
|
|
49
49
|
|
|
50
50
|
error_code: MowerErrorCode | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.ERROR_CODE})
|
|
51
|
+
"""None means no error code was supplied; UNKNOWN means an unknown error."""
|
|
51
52
|
battery: int | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.BATTERY})
|
|
52
53
|
mow_type: MowerMowType | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_TYPE})
|
|
53
54
|
mow_state: MowerDetailState | None = field(default=None, metadata={"dps": RoborockMowerDataProtocol.MOW_STATE})
|
|
@@ -20,17 +20,11 @@ documentation that informed this clean-room implementation comes from the
|
|
|
20
20
|
https://github.com/v1b3c0d3x3r/roborock-qseries-map-bridge
|
|
21
21
|
"""
|
|
22
22
|
|
|
23
|
-
import io
|
|
24
23
|
import math
|
|
25
24
|
import statistics
|
|
26
25
|
import struct
|
|
27
26
|
from dataclasses import dataclass, field, replace
|
|
28
27
|
|
|
29
|
-
from PIL import Image
|
|
30
|
-
from vacuum_map_parser_base.config.color import ColorsPalette, SupportedColor
|
|
31
|
-
from vacuum_map_parser_base.config.image_config import ImageConfig
|
|
32
|
-
from vacuum_map_parser_base.map_data import ImageData, MapData, Point
|
|
33
|
-
|
|
34
28
|
from roborock.data.b01_q10.b01_q10_containers import Q10RoborockPoint
|
|
35
29
|
from roborock.data.code_mappings import RoborockEnum
|
|
36
30
|
from roborock.data.containers import RoborockBase
|
|
@@ -44,10 +38,6 @@ from .b01_grid_layers import (
|
|
|
44
38
|
GridLayers,
|
|
45
39
|
decompose_grid,
|
|
46
40
|
)
|
|
47
|
-
from .map_parser import ParsedMapData
|
|
48
|
-
from .room_colors import adjacency_aware_room_colors
|
|
49
|
-
|
|
50
|
-
_MAP_FILE_FORMAT = "PNG"
|
|
51
41
|
|
|
52
42
|
# Semantic raster classes, confirmed against real ss07 captures (rendered and
|
|
53
43
|
# eyeballed): 243 is the background outside the home (~half the grid), 240 is
|
|
@@ -776,111 +766,3 @@ class B01Q10MapParserConfig:
|
|
|
776
766
|
|
|
777
767
|
map_scale: int = 4
|
|
778
768
|
"""Scale factor for the rendered map image."""
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
class B01Q10MapParser:
|
|
782
|
-
"""Decoder/renderer for Q10 ``MAP_RESPONSE`` (protocol 301) payloads."""
|
|
783
|
-
|
|
784
|
-
def __init__(self, config: B01Q10MapParserConfig | None = None) -> None:
|
|
785
|
-
self._config = config or B01Q10MapParserConfig()
|
|
786
|
-
|
|
787
|
-
@property
|
|
788
|
-
def config(self) -> B01Q10MapParserConfig:
|
|
789
|
-
"""The parser configuration (image scale, ...)."""
|
|
790
|
-
return self._config
|
|
791
|
-
|
|
792
|
-
def parse(self, payload: bytes) -> ParsedMapData:
|
|
793
|
-
"""Parse a raw Q10 map packet into a rendered PNG + ``MapData``."""
|
|
794
|
-
return self.parsed_from_packet(parse_map_packet(payload))
|
|
795
|
-
|
|
796
|
-
def parse_packet(self, packet: Q10MapPacket) -> ParsedMapData:
|
|
797
|
-
"""Render an already-decoded packet.
|
|
798
|
-
|
|
799
|
-
This preserves the public API used by the existing map trait. Callers
|
|
800
|
-
rendering a modified packet can use :meth:`parsed_from_packet` directly.
|
|
801
|
-
"""
|
|
802
|
-
return self.parsed_from_packet(packet)
|
|
803
|
-
|
|
804
|
-
def map_data_from_packet(self, packet: Q10MapPacket) -> MapData:
|
|
805
|
-
"""Build MapData with a rendered base image, without PNG serialization."""
|
|
806
|
-
image = self._render(packet)
|
|
807
|
-
|
|
808
|
-
map_data = MapData()
|
|
809
|
-
map_data.image = ImageData(
|
|
810
|
-
size=packet.width * packet.height,
|
|
811
|
-
top=0,
|
|
812
|
-
left=0,
|
|
813
|
-
height=packet.height,
|
|
814
|
-
width=packet.width,
|
|
815
|
-
image_config=ImageConfig(scale=self._config.map_scale),
|
|
816
|
-
data=image,
|
|
817
|
-
# ImageDimensions uses V1's bottom-up map convention before
|
|
818
|
-
# projecting into the top-down PNG. Q10 points are already
|
|
819
|
-
# top-down grid pixels, so this adapter cancels that standard flip
|
|
820
|
-
# and lets Q10 use the shared V1 ImageGenerator without moving
|
|
821
|
-
# overlays vertically.
|
|
822
|
-
img_transformation=lambda p: Point(p.x, packet.height - p.y - 1, p.a),
|
|
823
|
-
)
|
|
824
|
-
room_names = {room.id: room.name for room in packet.rooms}
|
|
825
|
-
if room_names:
|
|
826
|
-
map_data.additional_parameters["room_names"] = room_names
|
|
827
|
-
|
|
828
|
-
# Carpet cells are flat grid indices (y*width + x) in the same top-down
|
|
829
|
-
# pixel space as the rendered raster, so they line up with the image.
|
|
830
|
-
if packet.carpet_mask is not None:
|
|
831
|
-
map_data.carpet_map = {i for i, value in enumerate(packet.carpet_mask) if value}
|
|
832
|
-
|
|
833
|
-
return map_data
|
|
834
|
-
|
|
835
|
-
def parsed_from_packet(self, packet: Q10MapPacket) -> ParsedMapData:
|
|
836
|
-
"""Render a (possibly erase-modified) packet into a PNG + ``MapData``."""
|
|
837
|
-
map_data = self.map_data_from_packet(packet)
|
|
838
|
-
image_bytes = io.BytesIO()
|
|
839
|
-
if map_data.image is not None:
|
|
840
|
-
map_data.image.data.save(image_bytes, format=_MAP_FILE_FORMAT)
|
|
841
|
-
return ParsedMapData(image_content=image_bytes.getvalue(), map_data=map_data)
|
|
842
|
-
|
|
843
|
-
def _render(self, packet: Q10MapPacket) -> Image.Image:
|
|
844
|
-
"""Render the Q10 grid with the V1 map palette."""
|
|
845
|
-
palette = _build_palette(packet.grid, packet.width)
|
|
846
|
-
flat_palette = [channel for color in palette for channel in color]
|
|
847
|
-
p_img = Image.frombytes("P", (packet.width, packet.height), packet.grid)
|
|
848
|
-
p_img.putpalette(flat_palette, rawmode="RGBA")
|
|
849
|
-
img = p_img.convert("RGBA")
|
|
850
|
-
# The ss07 grid is stored top-down (row 0 = top of the home), so it is
|
|
851
|
-
# rendered as-is -- unlike the V1/Q7 convention, no vertical flip.
|
|
852
|
-
scale = self._config.map_scale
|
|
853
|
-
if scale > 1:
|
|
854
|
-
img = img.resize((packet.width * scale, packet.height * scale), resample=Image.Resampling.NEAREST)
|
|
855
|
-
return img
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
def _opaque(color: tuple[int, ...]) -> tuple[int, int, int, int]:
|
|
859
|
-
"""Return a palette color as RGBA."""
|
|
860
|
-
return (color[0], color[1], color[2], color[3] if len(color) == 4 else 255)
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
def _build_palette(grid: bytes, width: int) -> list[tuple[int, int, int, int]]:
|
|
864
|
-
"""Map Q10 cells onto the same colors used by the V1 map renderer."""
|
|
865
|
-
|
|
866
|
-
def room_id(value: int) -> int | None:
|
|
867
|
-
return max(1, value // 4) if 0 < value < _WALL_THRESHOLD else None
|
|
868
|
-
|
|
869
|
-
colors = ColorsPalette()
|
|
870
|
-
room_colors = adjacency_aware_room_colors(
|
|
871
|
-
grid,
|
|
872
|
-
width,
|
|
873
|
-
colors,
|
|
874
|
-
room_id,
|
|
875
|
-
)
|
|
876
|
-
outside = (0, 0, 0, 0)
|
|
877
|
-
palette = [outside] * 256
|
|
878
|
-
for value in {value for value in grid if 0 < value < _WALL_THRESHOLD}:
|
|
879
|
-
palette[value] = _opaque(room_colors[max(1, value // 4)])
|
|
880
|
-
wall = _opaque(colors.get_color(SupportedColor.GREY_WALL))
|
|
881
|
-
for value in range(_WALL_THRESHOLD, 256):
|
|
882
|
-
palette[value] = wall
|
|
883
|
-
palette[_UNSEGMENTED_FLOOR_VALUE] = _opaque(colors.get_color(SupportedColor.MAP_INSIDE))
|
|
884
|
-
palette[_BACKGROUND_VALUE] = outside
|
|
885
|
-
palette[0] = outside
|
|
886
|
-
return palette
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Compose a Q10 (B01/ss07) map into a single rendered result.
|
|
2
2
|
|
|
3
|
-
The :
|
|
4
|
-
into a :class:`~roborock.map.b01_q10_map_parser.Q10MapPacket`; this module
|
|
3
|
+
The :func:`~roborock.map.b01_q10_map_parser.parse_map_packet` function turns wire
|
|
4
|
+
bytes into a :class:`~roborock.map.b01_q10_map_parser.Q10MapPacket`; this module
|
|
5
5
|
combines that map-protocol packet with the latest trace-protocol packet and DPS
|
|
6
6
|
overlay snapshot. Calibration, path and position are derived from those source
|
|
7
7
|
objects rather than managed independently by callers.
|
|
@@ -18,9 +18,12 @@ import math
|
|
|
18
18
|
from collections.abc import Sequence
|
|
19
19
|
from dataclasses import dataclass
|
|
20
20
|
|
|
21
|
+
from PIL import Image
|
|
22
|
+
from vacuum_map_parser_base.config.color import ColorsPalette, SupportedColor
|
|
21
23
|
from vacuum_map_parser_base.config.drawable import Drawable
|
|
24
|
+
from vacuum_map_parser_base.config.image_config import ImageConfig
|
|
22
25
|
from vacuum_map_parser_base.config.size import Size, Sizes
|
|
23
|
-
from vacuum_map_parser_base.map_data import Area, MapData, Path, Point, Wall
|
|
26
|
+
from vacuum_map_parser_base.map_data import Area, ImageData, MapData, Path, Point, Wall
|
|
24
27
|
|
|
25
28
|
from roborock.exceptions import RoborockException
|
|
26
29
|
|
|
@@ -31,7 +34,9 @@ from .b01_grid_layers import (
|
|
|
31
34
|
solve_calibration_with_origin,
|
|
32
35
|
)
|
|
33
36
|
from .b01_q10_map_parser import (
|
|
34
|
-
|
|
37
|
+
_BACKGROUND_VALUE,
|
|
38
|
+
_UNSEGMENTED_FLOOR_VALUE,
|
|
39
|
+
_WALL_THRESHOLD,
|
|
35
40
|
B01Q10MapParserConfig,
|
|
36
41
|
Q10EraseZone,
|
|
37
42
|
Q10HistoricalTracePacket,
|
|
@@ -41,6 +46,7 @@ from .b01_q10_map_parser import (
|
|
|
41
46
|
)
|
|
42
47
|
from .b01_q10_overlays import ZONE_TYPE_NO_GO, ZONE_TYPE_NO_MOP, Q10Zone
|
|
43
48
|
from .map_parser import DEFAULT_DRAWABLES, MapParserConfig, _create_image_generator
|
|
49
|
+
from .room_colors import adjacency_aware_room_colors
|
|
44
50
|
|
|
45
51
|
# Path-units-per-pixel candidates for calibration. A dense ss07 path lands a
|
|
46
52
|
# best fit of 20.0 around the header origin -- ground-truthed June 2026 on the
|
|
@@ -97,7 +103,6 @@ def render_q10_map(
|
|
|
97
103
|
available trace and DPS overlays are projected and drawn in pixel space.
|
|
98
104
|
Raises :class:`RoborockException` if map rendering fails.
|
|
99
105
|
"""
|
|
100
|
-
parser = B01Q10MapParser(config)
|
|
101
106
|
trace_calibration = solve_q10_calibration(packet, trace)
|
|
102
107
|
vector_calibration = _vector_calibration(packet, trace_calibration)
|
|
103
108
|
|
|
@@ -109,7 +114,7 @@ def render_q10_map(
|
|
|
109
114
|
# areas disappear (as the app shows).
|
|
110
115
|
render_packet = erased_packet(packet, cells)
|
|
111
116
|
|
|
112
|
-
map_data =
|
|
117
|
+
map_data = _map_data_from_packet(render_packet, config=config)
|
|
113
118
|
if map_data.image is None:
|
|
114
119
|
raise RoborockException("Failed to render Q10 map image")
|
|
115
120
|
|
|
@@ -335,3 +340,84 @@ def _draw_map_content(
|
|
|
335
340
|
buffer = io.BytesIO()
|
|
336
341
|
map_data.image.data.save(buffer, format="PNG")
|
|
337
342
|
return buffer.getvalue()
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _map_data_from_packet(
|
|
346
|
+
packet: Q10MapPacket,
|
|
347
|
+
*,
|
|
348
|
+
config: B01Q10MapParserConfig,
|
|
349
|
+
) -> MapData:
|
|
350
|
+
"""Build MapData with a rendered base image, without PNG serialization."""
|
|
351
|
+
image = _render_base_grid(packet, scale=config.map_scale)
|
|
352
|
+
|
|
353
|
+
map_data = MapData()
|
|
354
|
+
map_data.image = ImageData(
|
|
355
|
+
size=packet.width * packet.height,
|
|
356
|
+
top=0,
|
|
357
|
+
left=0,
|
|
358
|
+
height=packet.height,
|
|
359
|
+
width=packet.width,
|
|
360
|
+
image_config=ImageConfig(scale=config.map_scale),
|
|
361
|
+
data=image,
|
|
362
|
+
# ImageDimensions uses V1's bottom-up map convention before
|
|
363
|
+
# projecting into the top-down PNG. Q10 points are already
|
|
364
|
+
# top-down grid pixels, so this adapter cancels that standard flip
|
|
365
|
+
# and lets Q10 use the shared V1 ImageGenerator without moving
|
|
366
|
+
# overlays vertically.
|
|
367
|
+
img_transformation=lambda p: Point(p.x, packet.height - p.y - 1, p.a),
|
|
368
|
+
)
|
|
369
|
+
room_names = {room.id: room.name for room in packet.rooms}
|
|
370
|
+
if room_names:
|
|
371
|
+
map_data.additional_parameters["room_names"] = room_names
|
|
372
|
+
|
|
373
|
+
# Carpet cells are flat grid indices (y*width + x) in the same top-down
|
|
374
|
+
# pixel space as the rendered raster, so they line up with the image.
|
|
375
|
+
if packet.carpet_mask is not None:
|
|
376
|
+
map_data.carpet_map = {i for i, value in enumerate(packet.carpet_mask) if value}
|
|
377
|
+
|
|
378
|
+
return map_data
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _render_base_grid(packet: Q10MapPacket, *, scale: int) -> Image.Image:
|
|
382
|
+
"""Render the Q10 grid with the V1 map palette."""
|
|
383
|
+
palette = _build_palette(packet.grid, packet.width)
|
|
384
|
+
flat_palette = [channel for color in palette for channel in color]
|
|
385
|
+
p_img = Image.frombytes("P", (packet.width, packet.height), packet.grid)
|
|
386
|
+
p_img.putpalette(flat_palette, rawmode="RGBA")
|
|
387
|
+
img = p_img.convert("RGBA")
|
|
388
|
+
# The ss07 grid is stored top-down (row 0 = top of the home), so it is
|
|
389
|
+
# rendered as-is -- unlike the V1/Q7 convention, no vertical flip.
|
|
390
|
+
if scale > 1:
|
|
391
|
+
img = img.resize((packet.width * scale, packet.height * scale), resample=Image.Resampling.NEAREST)
|
|
392
|
+
return img
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _opaque(color: tuple[int, ...]) -> tuple[int, int, int, int]:
|
|
396
|
+
"""Return a palette color as RGBA."""
|
|
397
|
+
return (color[0], color[1], color[2], color[3] if len(color) == 4 else 255)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _build_palette(grid: bytes, width: int) -> list[tuple[int, int, int, int]]:
|
|
401
|
+
"""Map Q10 cells onto the same colors used by the V1 map renderer."""
|
|
402
|
+
|
|
403
|
+
def room_id(value: int) -> int | None:
|
|
404
|
+
return max(1, value // 4) if 0 < value < _WALL_THRESHOLD else None
|
|
405
|
+
|
|
406
|
+
colors = ColorsPalette()
|
|
407
|
+
room_colors = adjacency_aware_room_colors(
|
|
408
|
+
grid,
|
|
409
|
+
width,
|
|
410
|
+
colors,
|
|
411
|
+
room_id,
|
|
412
|
+
)
|
|
413
|
+
outside = (0, 0, 0, 0)
|
|
414
|
+
palette = [outside] * 256
|
|
415
|
+
for value in {value for value in grid if 0 < value < _WALL_THRESHOLD}:
|
|
416
|
+
palette[value] = _opaque(room_colors[max(1, value // 4)])
|
|
417
|
+
wall = _opaque(colors.get_color(SupportedColor.GREY_WALL))
|
|
418
|
+
for value in range(_WALL_THRESHOLD, 256):
|
|
419
|
+
palette[value] = wall
|
|
420
|
+
palette[_UNSEGMENTED_FLOOR_VALUE] = _opaque(colors.get_color(SupportedColor.MAP_INSIDE))
|
|
421
|
+
palette[_BACKGROUND_VALUE] = outside
|
|
422
|
+
palette[0] = outside
|
|
423
|
+
return palette
|
|
@@ -10,7 +10,7 @@ import json
|
|
|
10
10
|
import logging
|
|
11
11
|
import struct
|
|
12
12
|
import time
|
|
13
|
-
from collections.abc import Callable
|
|
13
|
+
from collections.abc import Callable, Sequence
|
|
14
14
|
from dataclasses import asdict, replace
|
|
15
15
|
from enum import Enum
|
|
16
16
|
from typing import Any
|
|
@@ -45,6 +45,7 @@ from roborock.protocols.v1_protocol import SecurityData
|
|
|
45
45
|
from roborock.roborock_message import RoborockDataProtocol, RoborockMessage, RoborockMessageProtocol
|
|
46
46
|
from roborock.testing.channel import FakeChannel
|
|
47
47
|
from roborock.testing.simulator import RoborockDeviceSimulator
|
|
48
|
+
from roborock.util import get_next_int
|
|
48
49
|
|
|
49
50
|
_LOGGER = logging.getLogger(__name__)
|
|
50
51
|
|
|
@@ -255,8 +256,12 @@ class V1VacuumSimulator(RoborockDeviceSimulator):
|
|
|
255
256
|
|
|
256
257
|
# The simulated robot's own view of its map. Tests extend the path
|
|
257
258
|
# with advance_path() and move the robot via robot_position.
|
|
259
|
+
# Set map_parts > 1 (and optionally map_part_order) to split map
|
|
260
|
+
# responses across MAP_RESPONSE_PART (300) and MAP_RESPONSE (301) frames.
|
|
258
261
|
self.path_length = 0
|
|
259
262
|
self.robot_position = (25000, 24000, 90)
|
|
263
|
+
self.map_parts = 1
|
|
264
|
+
self.map_part_order: Sequence[int] | None = None
|
|
260
265
|
|
|
261
266
|
self.device_cache = DeviceCache(self.duid, InMemoryCache())
|
|
262
267
|
# The endpoint must fit the eight byte map response header field and the
|
|
@@ -416,11 +421,44 @@ class V1VacuumSimulator(RoborockDeviceSimulator):
|
|
|
416
421
|
data = header + body + digest_header
|
|
417
422
|
return data + hashlib.sha1(data, usedforsecurity=False).digest()
|
|
418
423
|
|
|
419
|
-
def
|
|
420
|
-
"""Frame the map as
|
|
424
|
+
def _map_response_messages(self, request_id: int, map_bytes: bytes) -> list[RoborockMessage]:
|
|
425
|
+
"""Frame the map as one or more encrypted binary messages as the device sends."""
|
|
426
|
+
if self.map_parts < 1:
|
|
427
|
+
raise ValueError("map_parts must be at least 1")
|
|
421
428
|
body = Utils.encrypt_cbc(gzip.compress(map_bytes), self.security_data.nonce)
|
|
422
429
|
header = struct.pack("<8s8sH6s", self.security_data.endpoint.encode(), bytes(8), request_id & 0xFFFF, bytes(6))
|
|
423
|
-
|
|
430
|
+
if self.map_parts == 1:
|
|
431
|
+
if self.map_part_order is not None and list(self.map_part_order) != [0]:
|
|
432
|
+
raise ValueError("map_part_order must be a permutation of range(map_parts)")
|
|
433
|
+
return [RoborockMessage(protocol=RoborockMessageProtocol.MAP_RESPONSE, payload=header + body)]
|
|
434
|
+
if self.map_parts > len(body):
|
|
435
|
+
raise ValueError("map_parts cannot exceed encrypted map body length")
|
|
436
|
+
|
|
437
|
+
# Keep the 24-byte header intact on the first part and split the encrypted
|
|
438
|
+
# body evenly into map_parts non-empty contiguous slices.
|
|
439
|
+
total = len(body)
|
|
440
|
+
boundaries = [(i * total) // self.map_parts for i in range(self.map_parts + 1)]
|
|
441
|
+
chunks = [body[boundaries[i] : boundaries[i + 1]] for i in range(self.map_parts)]
|
|
442
|
+
chunks[0] = header + chunks[0]
|
|
443
|
+
random_id = get_next_int(10000, 99999)
|
|
444
|
+
timestamp = int(time.time())
|
|
445
|
+
messages = [
|
|
446
|
+
RoborockMessage(
|
|
447
|
+
protocol=RoborockMessageProtocol.MAP_RESPONSE
|
|
448
|
+
if idx == len(chunks) - 1
|
|
449
|
+
else RoborockMessageProtocol.MAP_RESPONSE_PART,
|
|
450
|
+
payload=chunk,
|
|
451
|
+
seq=idx + 1,
|
|
452
|
+
random=random_id,
|
|
453
|
+
timestamp=timestamp,
|
|
454
|
+
)
|
|
455
|
+
for idx, chunk in enumerate(chunks)
|
|
456
|
+
]
|
|
457
|
+
if self.map_part_order is not None:
|
|
458
|
+
if sorted(self.map_part_order) != list(range(self.map_parts)):
|
|
459
|
+
raise ValueError("map_part_order must be a permutation of range(map_parts)")
|
|
460
|
+
messages = [messages[i] for i in self.map_part_order]
|
|
461
|
+
return messages
|
|
424
462
|
|
|
425
463
|
async def _handle_publish(self, message: RoborockMessage, channel: FakeChannel) -> None:
|
|
426
464
|
if not message.payload:
|
|
@@ -455,7 +493,8 @@ class V1VacuumSimulator(RoborockDeviceSimulator):
|
|
|
455
493
|
|
|
456
494
|
if method == "get_map_v1" and error is None and isinstance(result, bytes):
|
|
457
495
|
# Custom handlers return decompressed map bytes, just like the default.
|
|
458
|
-
|
|
496
|
+
for map_msg in self._map_response_messages(msg_id, result):
|
|
497
|
+
channel.notify_subscribers(map_msg)
|
|
459
498
|
return
|
|
460
499
|
|
|
461
500
|
response_data = {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Roborock cloud REST API clients.
|
|
2
|
+
|
|
3
|
+
The public entry points are :class:`RoborockApiClient` and :class:`UserWebApiClient`.
|
|
4
|
+
Endpoints are grouped by resource into submodules (``auth``, ``homes``, ``devices``,
|
|
5
|
+
``scenes``, ``jobs``, ``ota``, ``products``); each exposes a mixin that
|
|
6
|
+
``RoborockApiClient`` composes. Everything that was importable from the former
|
|
7
|
+
``roborock.web_api`` module is re-exported here for backwards compatibility.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from ._base import BASE_URLS, IotLoginInfo, RoborockApiBase
|
|
11
|
+
from ._request import PreparedRequest
|
|
12
|
+
from .auth import DEFAULT_AGREEMENT_MAJOR_VERSION, DEFAULT_AGREEMENT_MINOR_VERSION, AuthApi
|
|
13
|
+
from .client import RoborockApiClient, UserWebApiClient
|
|
14
|
+
from .devices import DevicesApi
|
|
15
|
+
from .homes import HomesApi
|
|
16
|
+
from .jobs import JobsApi
|
|
17
|
+
from .ota import OtaApi
|
|
18
|
+
from .products import ProductsApi
|
|
19
|
+
from .scenes import ScenesApi
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"BASE_URLS",
|
|
23
|
+
"DEFAULT_AGREEMENT_MAJOR_VERSION",
|
|
24
|
+
"DEFAULT_AGREEMENT_MINOR_VERSION",
|
|
25
|
+
"AuthApi",
|
|
26
|
+
"DevicesApi",
|
|
27
|
+
"HomesApi",
|
|
28
|
+
"IotLoginInfo",
|
|
29
|
+
"JobsApi",
|
|
30
|
+
"OtaApi",
|
|
31
|
+
"PreparedRequest",
|
|
32
|
+
"ProductsApi",
|
|
33
|
+
"RoborockApiBase",
|
|
34
|
+
"RoborockApiClient",
|
|
35
|
+
"ScenesApi",
|
|
36
|
+
"UserWebApiClient",
|
|
37
|
+
]
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Shared state and request-building helpers for the web API mixins.
|
|
2
|
+
|
|
3
|
+
Every resource mixin (``AuthApi``, ``HomesApi``, ``JobsApi`` ...) inherits from
|
|
4
|
+
``RoborockApiBase`` so that it can rely on the username, session, regional base
|
|
5
|
+
URL resolution and the two request builders defined here. ``RoborockApiClient``
|
|
6
|
+
composes the mixins into the single public client.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
import logging
|
|
12
|
+
import secrets
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
import aiohttp
|
|
16
|
+
|
|
17
|
+
from roborock.data import UserData
|
|
18
|
+
from roborock.exceptions import (
|
|
19
|
+
RoborockException,
|
|
20
|
+
RoborockInvalidCredentials,
|
|
21
|
+
RoborockInvalidEmail,
|
|
22
|
+
RoborockMissingParameters,
|
|
23
|
+
RoborockNoResponseFromBaseURL,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from ._hawk import _get_hawk_authentication
|
|
27
|
+
from ._request import PreparedRequest
|
|
28
|
+
|
|
29
|
+
_LOGGER = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
BASE_URLS = [
|
|
32
|
+
"https://usiot.roborock.com",
|
|
33
|
+
"https://euiot.roborock.com",
|
|
34
|
+
"https://cniot.roborock.com",
|
|
35
|
+
"https://ruiot.roborock.com",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class IotLoginInfo:
|
|
41
|
+
"""Information about the login to the iot server."""
|
|
42
|
+
|
|
43
|
+
base_url: str
|
|
44
|
+
country_code: str
|
|
45
|
+
country: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RoborockApiBase:
|
|
49
|
+
"""State and helpers shared by all web API resource mixins."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self, username: str, base_url: str | None = None, session: aiohttp.ClientSession | None = None
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Sample API Client."""
|
|
55
|
+
self._username = username
|
|
56
|
+
self._base_url = base_url
|
|
57
|
+
self._device_identifier = secrets.token_urlsafe(16)
|
|
58
|
+
self.session = session
|
|
59
|
+
self._iot_login_info: IotLoginInfo | None = None
|
|
60
|
+
self._base_urls = BASE_URLS if base_url is None else [base_url]
|
|
61
|
+
|
|
62
|
+
async def _get_iot_login_info(self) -> IotLoginInfo:
|
|
63
|
+
if self._iot_login_info is None:
|
|
64
|
+
for iot_url in self._base_urls:
|
|
65
|
+
url_request = PreparedRequest(iot_url, self.session)
|
|
66
|
+
response = await url_request.request(
|
|
67
|
+
"post",
|
|
68
|
+
"/api/v1/getUrlByEmail",
|
|
69
|
+
params={"email": self._username, "needtwostepauth": "false"},
|
|
70
|
+
)
|
|
71
|
+
if response is None:
|
|
72
|
+
continue
|
|
73
|
+
response_code = response.get("code")
|
|
74
|
+
if response_code != 200:
|
|
75
|
+
if response_code == 2003:
|
|
76
|
+
raise RoborockInvalidEmail("Your email was incorrectly formatted.")
|
|
77
|
+
elif response_code == 1001:
|
|
78
|
+
raise RoborockMissingParameters(
|
|
79
|
+
"You are missing parameters for this request, are you sure you entered your username?"
|
|
80
|
+
)
|
|
81
|
+
else:
|
|
82
|
+
raise RoborockException(f"{response.get('msg')} - response code: {response_code}")
|
|
83
|
+
country_code = response["data"]["countrycode"]
|
|
84
|
+
country = response["data"]["country"]
|
|
85
|
+
if country_code is not None or country is not None:
|
|
86
|
+
self._iot_login_info = IotLoginInfo(
|
|
87
|
+
base_url=response["data"]["url"],
|
|
88
|
+
country=country,
|
|
89
|
+
country_code=country_code,
|
|
90
|
+
)
|
|
91
|
+
_LOGGER.debug("Country determined to be %s and code is %s", country, country_code)
|
|
92
|
+
return self._iot_login_info
|
|
93
|
+
raise RoborockNoResponseFromBaseURL(
|
|
94
|
+
"No account was found for any base url we tried. Either your email is incorrect or we do not have a"
|
|
95
|
+
" record of the roborock server your device is on."
|
|
96
|
+
)
|
|
97
|
+
return self._iot_login_info
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
async def base_url(self):
|
|
101
|
+
if self._base_url is not None:
|
|
102
|
+
return self._base_url
|
|
103
|
+
return (await self._get_iot_login_info()).base_url
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
async def country(self):
|
|
107
|
+
return (await self._get_iot_login_info()).country
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
async def country_code(self):
|
|
111
|
+
return (await self._get_iot_login_info()).country_code
|
|
112
|
+
|
|
113
|
+
def _get_header_client_id(self):
|
|
114
|
+
md5 = hashlib.md5()
|
|
115
|
+
md5.update(self._username.encode())
|
|
116
|
+
md5.update(self._device_identifier.encode())
|
|
117
|
+
return base64.b64encode(md5.digest()).decode()
|
|
118
|
+
|
|
119
|
+
async def _iot_request(self, extra_headers: dict | None = None) -> PreparedRequest:
|
|
120
|
+
"""Build a request against the regional Base IoT server with the standard client headers."""
|
|
121
|
+
base_url = await self.base_url
|
|
122
|
+
headers = {"header_clientid": self._get_header_client_id(), **(extra_headers or {})}
|
|
123
|
+
return PreparedRequest(base_url, self.session, headers)
|
|
124
|
+
|
|
125
|
+
def _rriot_request(
|
|
126
|
+
self,
|
|
127
|
+
user_data: UserData,
|
|
128
|
+
path: str,
|
|
129
|
+
*,
|
|
130
|
+
formdata: dict | None = None,
|
|
131
|
+
params: dict | None = None,
|
|
132
|
+
body: dict | None = None,
|
|
133
|
+
extra_headers: dict | None = None,
|
|
134
|
+
) -> PreparedRequest:
|
|
135
|
+
"""Build a Hawk-authenticated request against the RRIoT server for ``path``.
|
|
136
|
+
|
|
137
|
+
The same ``formdata``/``params``/``body`` passed here must be sent on the request so
|
|
138
|
+
that the signed MAC matches what the server receives.
|
|
139
|
+
"""
|
|
140
|
+
rriot = user_data.rriot
|
|
141
|
+
if rriot is None:
|
|
142
|
+
raise RoborockException("rriot is none")
|
|
143
|
+
if rriot.r.a is None:
|
|
144
|
+
raise RoborockException("Missing field 'a' in rriot reference")
|
|
145
|
+
headers = {
|
|
146
|
+
"Authorization": _get_hawk_authentication(rriot, path, formdata=formdata, params=params, body=body),
|
|
147
|
+
**(extra_headers or {}),
|
|
148
|
+
}
|
|
149
|
+
return PreparedRequest(rriot.r.a, self.session, headers)
|
|
150
|
+
|
|
151
|
+
async def _get_home_id(self, user_data: UserData):
|
|
152
|
+
home_id_request = await self._iot_request()
|
|
153
|
+
home_id_response = await home_id_request.request(
|
|
154
|
+
"get",
|
|
155
|
+
"/api/v1/getHomeDetail",
|
|
156
|
+
headers={"Authorization": user_data.token},
|
|
157
|
+
)
|
|
158
|
+
if home_id_response is None:
|
|
159
|
+
raise RoborockException("home_id_response is None")
|
|
160
|
+
if home_id_response.get("code") != 200:
|
|
161
|
+
_LOGGER.info("Get Home Id failed with the following context: %s", home_id_response)
|
|
162
|
+
if home_id_response.get("code") == 2010:
|
|
163
|
+
raise RoborockInvalidCredentials(
|
|
164
|
+
f"Invalid credentials ({home_id_response.get('msg')}) - check your login and try again."
|
|
165
|
+
)
|
|
166
|
+
raise RoborockException(f"{home_id_response.get('msg')} - response code: {home_id_response.get('code')}")
|
|
167
|
+
|
|
168
|
+
return home_id_response["data"]["rrHomeId"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Hawk request signing for the RRIoT (``user_data.rriot.r.a``) API surface."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import secrets
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from roborock.data import RRiot
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _process_extra_hawk_values(values: dict | None) -> str:
|
|
15
|
+
if values is None:
|
|
16
|
+
return ""
|
|
17
|
+
else:
|
|
18
|
+
sorted_keys = sorted(values.keys())
|
|
19
|
+
result = []
|
|
20
|
+
for key in sorted_keys:
|
|
21
|
+
value = values.get(key)
|
|
22
|
+
result.append(f"{key}={value}")
|
|
23
|
+
return hashlib.md5("&".join(result).encode()).hexdigest()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _compact_json(body: dict) -> str:
|
|
27
|
+
"""Serialize a JSON body to the exact compact bytes that are both signed and sent."""
|
|
28
|
+
return json.dumps(body, separators=(",", ":"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _get_hawk_authentication(
|
|
32
|
+
rriot: RRiot,
|
|
33
|
+
url: str,
|
|
34
|
+
formdata: dict | None = None,
|
|
35
|
+
params: dict | None = None,
|
|
36
|
+
body: dict | None = None,
|
|
37
|
+
) -> str:
|
|
38
|
+
timestamp = math.floor(time.time())
|
|
39
|
+
nonce = secrets.token_urlsafe(6)
|
|
40
|
+
params_str = _process_extra_hawk_values(params)
|
|
41
|
+
if body is not None:
|
|
42
|
+
payload_str = hashlib.md5(_compact_json(body).encode()).hexdigest()
|
|
43
|
+
else:
|
|
44
|
+
payload_str = _process_extra_hawk_values(formdata)
|
|
45
|
+
|
|
46
|
+
prestr = ":".join(
|
|
47
|
+
[
|
|
48
|
+
rriot.u,
|
|
49
|
+
rriot.s,
|
|
50
|
+
nonce,
|
|
51
|
+
str(timestamp),
|
|
52
|
+
hashlib.md5(url.encode()).hexdigest(),
|
|
53
|
+
params_str,
|
|
54
|
+
payload_str,
|
|
55
|
+
]
|
|
56
|
+
)
|
|
57
|
+
mac = base64.b64encode(hmac.new(rriot.h.encode(), prestr.encode(), hashlib.sha256).digest()).decode()
|
|
58
|
+
return f'Hawk id="{rriot.u}",s="{rriot.s}",ts="{timestamp}",nonce="{nonce}",mac="{mac}"'
|