python-roborock 7.15.0__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.
Files changed (150) hide show
  1. {python_roborock-7.15.0 → python_roborock-7.15.2}/PKG-INFO +1 -1
  2. {python_roborock-7.15.0 → python_roborock-7.15.2}/pyproject.toml +1 -1
  3. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/data/mower/mower_code_mappings.py +4 -0
  4. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/data/mower/mower_containers.py +1 -0
  5. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/map/b01_q10_map_parser.py +0 -118
  6. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/map/b01_q10_render.py +92 -6
  7. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/protocols/v1_protocol.py +52 -6
  8. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/roborock_message.py +1 -0
  9. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/testing/v1_simulator.py +44 -5
  10. python_roborock-7.15.2/roborock/web_api/__init__.py +37 -0
  11. python_roborock-7.15.2/roborock/web_api/_base.py +168 -0
  12. python_roborock-7.15.2/roborock/web_api/_hawk.py +58 -0
  13. python_roborock-7.15.2/roborock/web_api/_request.py +44 -0
  14. python_roborock-7.15.2/roborock/web_api/auth.py +278 -0
  15. python_roborock-7.15.2/roborock/web_api/client.py +80 -0
  16. python_roborock-7.15.2/roborock/web_api/devices.py +84 -0
  17. python_roborock-7.15.2/roborock/web_api/homes.py +81 -0
  18. python_roborock-7.15.2/roborock/web_api/jobs.py +50 -0
  19. python_roborock-7.15.2/roborock/web_api/ota.py +44 -0
  20. python_roborock-7.15.2/roborock/web_api/products.py +49 -0
  21. python_roborock-7.15.2/roborock/web_api/scenes.py +28 -0
  22. python_roborock-7.15.0/roborock/web_api.py +0 -977
  23. {python_roborock-7.15.0 → python_roborock-7.15.2}/.gitignore +0 -0
  24. {python_roborock-7.15.0 → python_roborock-7.15.2}/LICENSE +0 -0
  25. {python_roborock-7.15.0 → python_roborock-7.15.2}/README.md +0 -0
  26. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/__init__.py +0 -0
  27. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/broadcast_protocol.py +0 -0
  28. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/callbacks.py +0 -0
  29. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/cli.py +0 -0
  30. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/const.py +0 -0
  31. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/data/__init__.py +0 -0
  32. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/data/b01_q10/__init__.py +0 -0
  33. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
  34. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
  35. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/data/b01_q7/__init__.py +0 -0
  36. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  37. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  38. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/data/camera.py +0 -0
  39. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/data/code_mappings.py +0 -0
  40. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/data/containers.py +0 -0
  41. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/data/dyad/__init__.py +0 -0
  42. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  43. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/data/dyad/dyad_containers.py +0 -0
  44. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/data/mower/__init__.py +0 -0
  45. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/data/v1/__init__.py +0 -0
  46. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/data/v1/v1_clean_modes.py +0 -0
  47. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/data/v1/v1_code_mappings.py +0 -0
  48. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/data/v1/v1_containers.py +0 -0
  49. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/data/v1/v1_dynamic_map.py +0 -0
  50. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/data/zeo/__init__.py +0 -0
  51. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  52. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/data/zeo/zeo_containers.py +0 -0
  53. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/device_features.py +0 -0
  54. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/README.md +0 -0
  55. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/__init__.py +0 -0
  56. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/cache.py +0 -0
  57. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/device.py +0 -0
  58. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/device_manager.py +0 -0
  59. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/file_cache.py +0 -0
  60. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/rpc/__init__.py +0 -0
  61. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/rpc/a01_channel.py +0 -0
  62. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/rpc/b01_q10_channel.py +0 -0
  63. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/rpc/b01_q7_channel.py +0 -0
  64. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/rpc/v1_channel.py +0 -0
  65. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/__init__.py +0 -0
  66. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/a01/__init__.py +0 -0
  67. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/a01/device_feature.py +0 -0
  68. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/b01/__init__.py +0 -0
  69. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/__init__.py +0 -0
  70. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/button_light.py +0 -0
  71. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/child_lock.py +0 -0
  72. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/clean_history.py +0 -0
  73. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/command.py +0 -0
  74. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/common.py +0 -0
  75. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/consumable.py +0 -0
  76. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/do_not_disturb.py +0 -0
  77. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/dust_collection.py +0 -0
  78. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/goto.py +0 -0
  79. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/map.py +0 -0
  80. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/maps.py +0 -0
  81. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/network_info.py +0 -0
  82. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/remote.py +0 -0
  83. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/status.py +0 -0
  84. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
  85. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/b01/q10/volume.py +0 -0
  86. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/b01/q7/__init__.py +0 -0
  87. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
  88. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/b01/q7/map.py +0 -0
  89. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/b01/q7/map_content.py +0 -0
  90. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/common.py +0 -0
  91. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/traits_mixin.py +0 -0
  92. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/v1/__init__.py +0 -0
  93. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/v1/child_lock.py +0 -0
  94. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/v1/clean_summary.py +0 -0
  95. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/v1/command.py +0 -0
  96. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/v1/common.py +0 -0
  97. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/v1/consumeable.py +0 -0
  98. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/v1/device_features.py +0 -0
  99. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  100. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  101. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  102. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/v1/home.py +0 -0
  103. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/v1/led_status.py +0 -0
  104. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/v1/map_content.py +0 -0
  105. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/v1/maps.py +0 -0
  106. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/v1/mop_dryer.py +0 -0
  107. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/v1/network_info.py +0 -0
  108. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/v1/obstacle_photos.py +0 -0
  109. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/v1/rooms.py +0 -0
  110. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/v1/routines.py +0 -0
  111. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  112. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/v1/status.py +0 -0
  113. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  114. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/v1/volume.py +0 -0
  115. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  116. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/transport/__init__.py +0 -0
  117. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/transport/channel.py +0 -0
  118. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/transport/local_channel.py +0 -0
  119. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/devices/transport/mqtt_channel.py +0 -0
  120. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/diagnostics.py +0 -0
  121. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/exceptions.py +0 -0
  122. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/map/__init__.py +0 -0
  123. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/map/b01_grid_layers.py +0 -0
  124. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/map/b01_map_parser.py +0 -0
  125. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/map/b01_q10_overlays.py +0 -0
  126. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/map/dynamic_blocks.py +0 -0
  127. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/map/map_parser.py +0 -0
  128. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/map/proto/__init__.py +0 -0
  129. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/map/proto/b01_scmap.proto +0 -0
  130. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/map/proto/b01_scmap_pb2.py +0 -0
  131. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/map/room_colors.py +0 -0
  132. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/mqtt/__init__.py +0 -0
  133. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/mqtt/health_manager.py +0 -0
  134. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/mqtt/roborock_session.py +0 -0
  135. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/mqtt/session.py +0 -0
  136. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/protocol.py +0 -0
  137. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/protocols/__init__.py +0 -0
  138. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/protocols/a01_protocol.py +0 -0
  139. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/protocols/b01_q10_protocol.py +0 -0
  140. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/protocols/b01_q7_protocol.py +0 -0
  141. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/protocols/camera_signaling.py +0 -0
  142. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/py.typed +0 -0
  143. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/roborock_typing.py +0 -0
  144. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/testing/__init__.py +0 -0
  145. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/testing/a01_simulator.py +0 -0
  146. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/testing/b01_q10_simulator.py +0 -0
  147. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/testing/channel.py +0 -0
  148. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/testing/cloud.py +0 -0
  149. {python_roborock-7.15.0 → python_roborock-7.15.2}/roborock/testing/simulator.py +0 -0
  150. {python_roborock-7.15.0 → 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.0
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.0"
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"
@@ -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 :class:`~roborock.map.b01_q10_map_parser.B01Q10MapParser` turns wire bytes
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
- B01Q10MapParser,
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 = parser.map_data_from_packet(render_packet)
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
@@ -306,16 +306,29 @@ def create_blob_response_decoder() -> Callable[[RoborockMessage], BlobResponse |
306
306
  return _decode_blob_response
307
307
 
308
308
 
309
+ # Number of incomplete multi-part map responses kept while waiting for missing parts.
310
+ _MAX_PENDING_MAP_RESPONSES = 4
311
+
312
+
309
313
  def create_map_response_decoder(security_data: SecurityData) -> Callable[[RoborockMessage], MapResponse | None]:
310
- """Create a decoder for V1 map response messages."""
314
+ """Create a decoder for V1 map response messages.
311
315
 
312
- def _decode_map_response(message: RoborockMessage) -> MapResponse | None:
313
- """Decode a V1 map response message."""
314
- if not message.payload or len(message.payload) < 24:
316
+ Large maps are split into multiple messages that share the same ``random``
317
+ and ``timestamp`` and are numbered by ``seq``. All parts except the last
318
+ one use protocol ``MAP_RESPONSE_PART`` (300), the last one uses
319
+ ``MAP_RESPONSE`` (301). Only the first part starts with the map header.
320
+ Parts may arrive out of order, so they are buffered until complete.
321
+ """
322
+ endpoint_bytes = security_data.endpoint.encode()
323
+ # (random, timestamp) -> seq -> (is last part, payload)
324
+ pending: dict[tuple[int, int], dict[int, tuple[bool, bytes]]] = {}
325
+
326
+ def _decode_payload(payload: bytes) -> MapResponse | None:
327
+ if len(payload) < 24:
315
328
  raise RoborockException("Invalid V1 map response format: missing payload")
316
- header, body = message.payload[:24], message.payload[24:]
329
+ header, body = payload[:24], payload[24:]
317
330
  [endpoint, _, request_id, _] = struct.unpack("<8s8sH6s", header)
318
- if not endpoint.decode().startswith(security_data.endpoint):
331
+ if not endpoint.startswith(endpoint_bytes):
319
332
  _LOGGER.debug("Received map response not requested by this device, ignoring.")
320
333
  return None
321
334
  try:
@@ -325,6 +338,39 @@ def create_map_response_decoder(security_data: SecurityData) -> Callable[[Roboro
325
338
  decompressed = Utils.decompress(decrypted)
326
339
  return MapResponse(request_id=request_id, data=decompressed)
327
340
 
341
+ def _remember(key: tuple[int, int], parts: dict[int, tuple[bool, bytes]]) -> None:
342
+ pending[key] = parts
343
+ while len(pending) > _MAX_PENDING_MAP_RESPONSES:
344
+ del pending[next(iter(pending))]
345
+
346
+ def _add_part(key: tuple[int, int], seq: int, is_last: bool, payload: bytes) -> MapResponse | None:
347
+ parts = pending.pop(key, {})
348
+ parts[seq] = (is_last, payload)
349
+ seqs = sorted(parts)
350
+ complete = (
351
+ parts[seqs[-1]][0]
352
+ and seqs == list(range(seqs[0], seqs[-1] + 1))
353
+ and parts[seqs[0]][1].startswith(endpoint_bytes)
354
+ )
355
+ if not complete:
356
+ _remember(key, parts)
357
+ return None
358
+ return _decode_payload(b"".join(parts[s][1] for s in seqs))
359
+
360
+ def _decode_map_response(message: RoborockMessage) -> MapResponse | None:
361
+ """Decode a V1 map response message."""
362
+ if not message.payload:
363
+ raise RoborockException("Invalid V1 map response format: missing payload")
364
+ payload = message.payload
365
+ key = (message.random, message.timestamp)
366
+ is_last = message.protocol == RoborockMessageProtocol.MAP_RESPONSE
367
+ if message.protocol == RoborockMessageProtocol.MAP_RESPONSE_PART or (is_last and key in pending):
368
+ return _add_part(key, message.seq, is_last, payload)
369
+ if is_last and not payload.startswith(endpoint_bytes):
370
+ # Possibly the last part of a multi-part response that arrived first.
371
+ _remember(key, {message.seq: (True, payload)})
372
+ return _decode_payload(payload)
373
+
328
374
  return _decode_map_response
329
375
 
330
376
 
@@ -15,6 +15,7 @@ class RoborockMessageProtocol(RoborockEnum):
15
15
  GENERAL_RESPONSE = 5
16
16
  RPC_REQUEST = 101
17
17
  RPC_RESPONSE = 102
18
+ MAP_RESPONSE_PART = 300 # Non-final part of a map response split into multiple messages
18
19
  MAP_RESPONSE = 301
19
20
 
20
21
 
@@ -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 _map_response_message(self, request_id: int, map_bytes: bytes) -> RoborockMessage:
420
- """Frame the map as the encrypted binary response the device sends."""
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
- return RoborockMessage(protocol=RoborockMessageProtocol.MAP_RESPONSE, payload=header + body)
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
- channel.notify_subscribers(self._map_response_message(msg_id, result))
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
+ ]