python-roborock 7.15.1__tar.gz → 7.15.3__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.1 → python_roborock-7.15.3}/PKG-INFO +1 -1
  2. {python_roborock-7.15.1 → python_roborock-7.15.3}/pyproject.toml +1 -1
  3. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/data/dyad/dyad_code_mappings.py +5 -1
  4. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/data/mower/mower_code_mappings.py +4 -0
  5. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/data/mower/mower_containers.py +1 -0
  6. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/map/b01_q10_map_parser.py +0 -118
  7. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/map/b01_q10_render.py +92 -6
  8. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/testing/v1_simulator.py +44 -5
  9. python_roborock-7.15.3/roborock/web_api/__init__.py +37 -0
  10. python_roborock-7.15.3/roborock/web_api/_base.py +168 -0
  11. python_roborock-7.15.3/roborock/web_api/_hawk.py +58 -0
  12. python_roborock-7.15.3/roborock/web_api/_request.py +44 -0
  13. python_roborock-7.15.3/roborock/web_api/auth.py +278 -0
  14. python_roborock-7.15.3/roborock/web_api/client.py +80 -0
  15. python_roborock-7.15.3/roborock/web_api/devices.py +84 -0
  16. python_roborock-7.15.3/roborock/web_api/homes.py +81 -0
  17. python_roborock-7.15.3/roborock/web_api/jobs.py +50 -0
  18. python_roborock-7.15.3/roborock/web_api/ota.py +44 -0
  19. python_roborock-7.15.3/roborock/web_api/products.py +49 -0
  20. python_roborock-7.15.3/roborock/web_api/scenes.py +28 -0
  21. python_roborock-7.15.1/roborock/web_api.py +0 -977
  22. {python_roborock-7.15.1 → python_roborock-7.15.3}/.gitignore +0 -0
  23. {python_roborock-7.15.1 → python_roborock-7.15.3}/LICENSE +0 -0
  24. {python_roborock-7.15.1 → python_roborock-7.15.3}/README.md +0 -0
  25. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/__init__.py +0 -0
  26. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/broadcast_protocol.py +0 -0
  27. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/callbacks.py +0 -0
  28. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/cli.py +0 -0
  29. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/const.py +0 -0
  30. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/data/__init__.py +0 -0
  31. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/data/b01_q10/__init__.py +0 -0
  32. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
  33. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
  34. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/data/b01_q7/__init__.py +0 -0
  35. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  36. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  37. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/data/camera.py +0 -0
  38. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/data/code_mappings.py +0 -0
  39. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/data/containers.py +0 -0
  40. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/data/dyad/__init__.py +0 -0
  41. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/data/dyad/dyad_containers.py +0 -0
  42. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/data/mower/__init__.py +0 -0
  43. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/data/v1/__init__.py +0 -0
  44. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/data/v1/v1_clean_modes.py +0 -0
  45. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/data/v1/v1_code_mappings.py +0 -0
  46. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/data/v1/v1_containers.py +0 -0
  47. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/data/v1/v1_dynamic_map.py +0 -0
  48. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/data/zeo/__init__.py +0 -0
  49. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  50. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/data/zeo/zeo_containers.py +0 -0
  51. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/device_features.py +0 -0
  52. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/README.md +0 -0
  53. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/__init__.py +0 -0
  54. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/cache.py +0 -0
  55. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/device.py +0 -0
  56. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/device_manager.py +0 -0
  57. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/file_cache.py +0 -0
  58. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/rpc/__init__.py +0 -0
  59. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/rpc/a01_channel.py +0 -0
  60. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/rpc/b01_q10_channel.py +0 -0
  61. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/rpc/b01_q7_channel.py +0 -0
  62. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/rpc/v1_channel.py +0 -0
  63. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/__init__.py +0 -0
  64. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/a01/__init__.py +0 -0
  65. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/a01/device_feature.py +0 -0
  66. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/b01/__init__.py +0 -0
  67. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/b01/q10/__init__.py +0 -0
  68. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/b01/q10/button_light.py +0 -0
  69. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/b01/q10/child_lock.py +0 -0
  70. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/b01/q10/clean_history.py +0 -0
  71. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/b01/q10/command.py +0 -0
  72. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/b01/q10/common.py +0 -0
  73. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/b01/q10/consumable.py +0 -0
  74. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/b01/q10/do_not_disturb.py +0 -0
  75. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/b01/q10/dust_collection.py +0 -0
  76. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/b01/q10/goto.py +0 -0
  77. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/b01/q10/map.py +0 -0
  78. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/b01/q10/maps.py +0 -0
  79. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/b01/q10/network_info.py +0 -0
  80. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/b01/q10/remote.py +0 -0
  81. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/b01/q10/status.py +0 -0
  82. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
  83. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/b01/q10/volume.py +0 -0
  84. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/b01/q7/__init__.py +0 -0
  85. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
  86. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/b01/q7/map.py +0 -0
  87. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/b01/q7/map_content.py +0 -0
  88. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/common.py +0 -0
  89. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/traits_mixin.py +0 -0
  90. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/v1/__init__.py +0 -0
  91. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/v1/child_lock.py +0 -0
  92. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/v1/clean_summary.py +0 -0
  93. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/v1/command.py +0 -0
  94. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/v1/common.py +0 -0
  95. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/v1/consumeable.py +0 -0
  96. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/v1/device_features.py +0 -0
  97. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  98. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  99. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  100. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/v1/home.py +0 -0
  101. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/v1/led_status.py +0 -0
  102. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/v1/map_content.py +0 -0
  103. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/v1/maps.py +0 -0
  104. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/v1/mop_dryer.py +0 -0
  105. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/v1/network_info.py +0 -0
  106. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/v1/obstacle_photos.py +0 -0
  107. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/v1/rooms.py +0 -0
  108. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/v1/routines.py +0 -0
  109. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  110. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/v1/status.py +0 -0
  111. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  112. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/v1/volume.py +0 -0
  113. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  114. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/transport/__init__.py +0 -0
  115. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/transport/channel.py +0 -0
  116. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/transport/local_channel.py +0 -0
  117. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/devices/transport/mqtt_channel.py +0 -0
  118. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/diagnostics.py +0 -0
  119. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/exceptions.py +0 -0
  120. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/map/__init__.py +0 -0
  121. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/map/b01_grid_layers.py +0 -0
  122. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/map/b01_map_parser.py +0 -0
  123. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/map/b01_q10_overlays.py +0 -0
  124. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/map/dynamic_blocks.py +0 -0
  125. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/map/map_parser.py +0 -0
  126. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/map/proto/__init__.py +0 -0
  127. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/map/proto/b01_scmap.proto +0 -0
  128. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/map/proto/b01_scmap_pb2.py +0 -0
  129. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/map/room_colors.py +0 -0
  130. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/mqtt/__init__.py +0 -0
  131. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/mqtt/health_manager.py +0 -0
  132. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/mqtt/roborock_session.py +0 -0
  133. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/mqtt/session.py +0 -0
  134. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/protocol.py +0 -0
  135. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/protocols/__init__.py +0 -0
  136. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/protocols/a01_protocol.py +0 -0
  137. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/protocols/b01_q10_protocol.py +0 -0
  138. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/protocols/b01_q7_protocol.py +0 -0
  139. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/protocols/camera_signaling.py +0 -0
  140. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/protocols/v1_protocol.py +0 -0
  141. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/py.typed +0 -0
  142. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/roborock_message.py +0 -0
  143. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/roborock_typing.py +0 -0
  144. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/testing/__init__.py +0 -0
  145. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/testing/a01_simulator.py +0 -0
  146. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/testing/b01_q10_simulator.py +0 -0
  147. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/testing/channel.py +0 -0
  148. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/testing/cloud.py +0 -0
  149. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/testing/simulator.py +0 -0
  150. {python_roborock-7.15.1 → python_roborock-7.15.3}/roborock/util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-roborock
3
- Version: 7.15.1
3
+ Version: 7.15.3
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.1"
3
+ version = "7.15.3"
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"
@@ -45,10 +45,14 @@ class DyadMode(RoborockEnum):
45
45
 
46
46
 
47
47
  class DyadCleanMode(RoborockEnum):
48
+ unknown = -1
48
49
  auto = 1
49
50
  max = 2
50
- dehydration = 3
51
+ dehydration = 3 # The F25 Ultra reports its "Sponge" mode with this code.
51
52
  power_saving = 4
53
+ # Heated modes on the F25 Ultra, confirmed by switching modes on the device.
54
+ hot_water = 5
55
+ steam = 6
52
56
 
53
57
 
54
58
  class DyadSuction(RoborockEnum):
@@ -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
@@ -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
+ ]
@@ -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"]