python-roborock 7.11.1__tar.gz → 7.12.1__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 (134) hide show
  1. {python_roborock-7.11.1 → python_roborock-7.12.1}/.gitignore +3 -0
  2. {python_roborock-7.11.1 → python_roborock-7.12.1}/PKG-INFO +1 -1
  3. {python_roborock-7.11.1 → python_roborock-7.12.1}/pyproject.toml +5 -1
  4. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/data/v1/v1_clean_modes.py +31 -4
  5. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/v1/status.py +3 -0
  6. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/map/b01_q10_map_parser.py +181 -28
  7. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/map/b01_q10_render.py +4 -4
  8. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/protocols/b01_q10_protocol.py +14 -10
  9. {python_roborock-7.11.1 → python_roborock-7.12.1}/LICENSE +0 -0
  10. {python_roborock-7.11.1 → python_roborock-7.12.1}/README.md +0 -0
  11. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/__init__.py +0 -0
  12. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/broadcast_protocol.py +0 -0
  13. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/callbacks.py +0 -0
  14. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/cli.py +0 -0
  15. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/const.py +0 -0
  16. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/data/__init__.py +0 -0
  17. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/data/b01_q10/__init__.py +0 -0
  18. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
  19. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
  20. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/data/b01_q7/__init__.py +0 -0
  21. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  22. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  23. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/data/code_mappings.py +0 -0
  24. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/data/containers.py +0 -0
  25. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/data/dyad/__init__.py +0 -0
  26. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  27. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/data/dyad/dyad_containers.py +0 -0
  28. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/data/mower/__init__.py +0 -0
  29. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/data/mower/mower_code_mappings.py +0 -0
  30. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/data/mower/mower_containers.py +0 -0
  31. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/data/v1/__init__.py +0 -0
  32. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/data/v1/v1_code_mappings.py +0 -0
  33. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/data/v1/v1_containers.py +0 -0
  34. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/data/zeo/__init__.py +0 -0
  35. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  36. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/data/zeo/zeo_containers.py +0 -0
  37. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/device_features.py +0 -0
  38. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/README.md +0 -0
  39. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/__init__.py +0 -0
  40. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/cache.py +0 -0
  41. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/device.py +0 -0
  42. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/device_manager.py +0 -0
  43. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/file_cache.py +0 -0
  44. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/rpc/__init__.py +0 -0
  45. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/rpc/a01_channel.py +0 -0
  46. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/rpc/b01_q10_channel.py +0 -0
  47. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/rpc/b01_q7_channel.py +0 -0
  48. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/rpc/v1_channel.py +0 -0
  49. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/__init__.py +0 -0
  50. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/a01/__init__.py +0 -0
  51. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/a01/device_feature.py +0 -0
  52. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/b01/__init__.py +0 -0
  53. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/b01/q10/__init__.py +0 -0
  54. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/b01/q10/button_light.py +0 -0
  55. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/b01/q10/child_lock.py +0 -0
  56. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/b01/q10/clean_history.py +0 -0
  57. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/b01/q10/command.py +0 -0
  58. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/b01/q10/common.py +0 -0
  59. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/b01/q10/consumable.py +0 -0
  60. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/b01/q10/do_not_disturb.py +0 -0
  61. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/b01/q10/dust_collection.py +0 -0
  62. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/b01/q10/goto.py +0 -0
  63. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/b01/q10/map.py +0 -0
  64. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/b01/q10/maps.py +0 -0
  65. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/b01/q10/network_info.py +0 -0
  66. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/b01/q10/remote.py +0 -0
  67. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/b01/q10/status.py +0 -0
  68. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
  69. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/b01/q10/volume.py +0 -0
  70. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/b01/q7/__init__.py +0 -0
  71. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
  72. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/b01/q7/map.py +0 -0
  73. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/b01/q7/map_content.py +0 -0
  74. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/common.py +0 -0
  75. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/traits_mixin.py +0 -0
  76. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/v1/__init__.py +0 -0
  77. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/v1/child_lock.py +0 -0
  78. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/v1/clean_summary.py +0 -0
  79. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/v1/command.py +0 -0
  80. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/v1/common.py +0 -0
  81. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/v1/consumeable.py +0 -0
  82. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/v1/device_features.py +0 -0
  83. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  84. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  85. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  86. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/v1/home.py +0 -0
  87. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/v1/led_status.py +0 -0
  88. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/v1/map_content.py +0 -0
  89. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/v1/maps.py +0 -0
  90. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/v1/mop_dryer.py +0 -0
  91. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/v1/network_info.py +0 -0
  92. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/v1/obstacle_photos.py +0 -0
  93. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/v1/rooms.py +0 -0
  94. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/v1/routines.py +0 -0
  95. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  96. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  97. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/v1/volume.py +0 -0
  98. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  99. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/transport/__init__.py +0 -0
  100. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/transport/channel.py +0 -0
  101. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/transport/local_channel.py +0 -0
  102. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/devices/transport/mqtt_channel.py +0 -0
  103. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/diagnostics.py +0 -0
  104. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/exceptions.py +0 -0
  105. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/map/__init__.py +0 -0
  106. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/map/b01_grid_layers.py +0 -0
  107. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/map/b01_map_parser.py +0 -0
  108. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/map/b01_q10_overlays.py +0 -0
  109. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/map/map_parser.py +0 -0
  110. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/map/proto/__init__.py +0 -0
  111. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/map/proto/b01_scmap.proto +0 -0
  112. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/map/proto/b01_scmap_pb2.py +0 -0
  113. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/map/room_colors.py +0 -0
  114. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/mqtt/__init__.py +0 -0
  115. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/mqtt/health_manager.py +0 -0
  116. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/mqtt/roborock_session.py +0 -0
  117. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/mqtt/session.py +0 -0
  118. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/protocol.py +0 -0
  119. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/protocols/__init__.py +0 -0
  120. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/protocols/a01_protocol.py +0 -0
  121. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/protocols/b01_q7_protocol.py +0 -0
  122. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/protocols/v1_protocol.py +0 -0
  123. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/py.typed +0 -0
  124. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/roborock_message.py +0 -0
  125. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/roborock_typing.py +0 -0
  126. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/testing/__init__.py +0 -0
  127. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/testing/a01_simulator.py +0 -0
  128. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/testing/b01_q10_simulator.py +0 -0
  129. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/testing/channel.py +0 -0
  130. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/testing/cloud.py +0 -0
  131. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/testing/simulator.py +0 -0
  132. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/testing/v1_simulator.py +0 -0
  133. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/util.py +0 -0
  134. {python_roborock-7.11.1 → python_roborock-7.12.1}/roborock/web_api.py +0 -0
@@ -19,3 +19,6 @@ docs/_build/
19
19
 
20
20
  # GitHub App credentials
21
21
  gha-creds-*.json
22
+
23
+ # pytest-benchmark storage
24
+ .benchmarks/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-roborock
3
- Version: 7.11.1
3
+ Version: 7.12.1
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.11.1"
3
+ version = "7.12.1"
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"
@@ -67,6 +67,8 @@ dev = [
67
67
  "syrupy>=4.9.1,<6",
68
68
  "pdoc>=15.0.4,<17",
69
69
  "pytest-cov>=7.0.0",
70
+ "zmqtt>=0.2.0,<0.3.0",
71
+ "pytest-benchmark>=5.1.0",
70
72
  ]
71
73
 
72
74
  [tool.hatch.build.targets.sdist]
@@ -146,6 +148,8 @@ module = ["roborock.map.proto.*"]
146
148
  ignore_errors = true
147
149
 
148
150
  [tool.pytest.ini_options]
151
+ addopts = "-m 'not mqtt_broker'"
152
+ markers = ["mqtt_broker: tests requiring a real MQTT broker"]
149
153
  asyncio_mode = "auto"
150
154
  asyncio_default_fixture_loop_scope = "function"
151
155
  timeout = 30
@@ -57,9 +57,10 @@ class WaterModes(RoborockModeEnum):
57
57
  SMART_MODE = ("smart_mode", 209)
58
58
  PURE_WATER_FLOW_START = ("slight", 221)
59
59
  PURE_WATER_FLOW_SMALL = ("low", 225)
60
+ PURE_WATER_FLOW_GENTLE = ("gentle", 230)
60
61
  PURE_WATER_FLOW_MIDDLE = ("medium", 235)
61
- PURE_WATER_FLOW_LARGE = ("moderate", 245)
62
- PURE_WATER_SUPER_BEGIN = ("high", 248)
62
+ PURE_WATER_FLOW_LARGE = ("moderate", 240)
63
+ PURE_WATER_SUPER_BEGIN = ("high", 245)
63
64
  PURE_WATER_FLOW_END = ("extreme", 250)
64
65
 
65
66
 
@@ -91,12 +92,25 @@ WATER_SLIDE_MODE_MAPPING: dict[int, WaterModes] = {
91
92
  200: WaterModes.OFF,
92
93
  221: WaterModes.PURE_WATER_FLOW_START,
93
94
  225: WaterModes.PURE_WATER_FLOW_SMALL,
95
+ 230: WaterModes.PURE_WATER_FLOW_GENTLE,
94
96
  235: WaterModes.PURE_WATER_FLOW_MIDDLE,
95
- 245: WaterModes.PURE_WATER_FLOW_LARGE,
96
- 248: WaterModes.PURE_WATER_SUPER_BEGIN,
97
+ 240: WaterModes.PURE_WATER_FLOW_LARGE,
98
+ 245: WaterModes.PURE_WATER_SUPER_BEGIN,
97
99
  250: WaterModes.PURE_WATER_FLOW_END,
98
100
  }
99
101
 
102
+ # Water slide devices report the raw slider position (1-30) as 220 + position.
103
+ # The app groups positions into named ranges, keyed here by their last position.
104
+ _WATER_SLIDE_RANGES: tuple[tuple[int, WaterModes], ...] = (
105
+ (3, WaterModes.PURE_WATER_FLOW_START),
106
+ (7, WaterModes.PURE_WATER_FLOW_SMALL),
107
+ (12, WaterModes.PURE_WATER_FLOW_GENTLE),
108
+ (17, WaterModes.PURE_WATER_FLOW_MIDDLE),
109
+ (22, WaterModes.PURE_WATER_FLOW_LARGE),
110
+ (27, WaterModes.PURE_WATER_SUPER_BEGIN),
111
+ (30, WaterModes.PURE_WATER_FLOW_END),
112
+ )
113
+
100
114
  ModeEnumT = TypeVar("ModeEnumT", bound=RoborockModeEnum)
101
115
 
102
116
 
@@ -183,6 +197,19 @@ def get_water_modes(features: DeviceFeatures) -> list[WaterModes]:
183
197
  return supported_modes
184
198
 
185
199
 
200
+ def get_water_slide_mode_name(code: int) -> str | None:
201
+ """Get the app's range name for a water slide code, or None if out of range."""
202
+ if code == 200:
203
+ return WaterModes.OFF.value
204
+ position = code - 220
205
+ if position < 1:
206
+ return None
207
+ for last_position, mode in _WATER_SLIDE_RANGES:
208
+ if position <= last_position:
209
+ return mode.value
210
+ return None
211
+
212
+
186
213
  def get_water_mode_mapping(features: DeviceFeatures) -> dict[int, str]:
187
214
  """Get water mode mapping by supported feature set.
188
215
 
@@ -15,6 +15,7 @@ from roborock import (
15
15
  get_current_cleaning_mode,
16
16
  get_water_mode_mapping,
17
17
  get_water_modes,
18
+ get_water_slide_mode_name,
18
19
  resolve_cleaning_mode,
19
20
  )
20
21
  from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener
@@ -103,6 +104,8 @@ class StatusTrait(StatusV2, common.V1TraitMixin, TraitUpdateListener):
103
104
  def water_mode_name(self) -> str | None:
104
105
  if self.water_box_mode is None:
105
106
  return None
107
+ if self._device_features_trait.is_water_slide_mode_supported:
108
+ return get_water_slide_mode_name(self.water_box_mode)
106
109
  return self.water_mode_mapping.get(self.water_box_mode)
107
110
 
108
111
  @property
@@ -1,8 +1,9 @@
1
1
  """Parser for Roborock Q10 (B01/ss07) map packets.
2
2
 
3
- Q10 devices deliver map data as a protocol-301 ``MAP_RESPONSE`` message after a
4
- ``dpMultiMap`` list/get request. Unlike the Q7 ``SCMap`` protobuf
5
- format, the Q10 uses a custom, unencrypted binary packet:
3
+ Q10 devices deliver map data as protocol-301 ``MAP_RESPONSE`` pushes. Current
4
+ maps follow a read-only status request, while saved-map and clean-record detail
5
+ packets follow their respective ``select`` requests. Unlike the Q7 ``SCMap``
6
+ protobuf format, the Q10 uses a custom, unencrypted binary packet:
6
7
 
7
8
  - ``01 01`` marker, then a ``u32be`` map id (bytes 2-5) and two consecutive
8
9
  ``u16be`` dimensions: grid width (bytes 7-8) and grid height (bytes 9-10).
@@ -22,6 +23,7 @@ https://github.com/v1b3c0d3x3r/roborock-qseries-map-bridge
22
23
  import io
23
24
  import math
24
25
  import statistics
26
+ import struct
25
27
  from dataclasses import dataclass, field, replace
26
28
 
27
29
  from PIL import Image
@@ -30,6 +32,7 @@ from vacuum_map_parser_base.config.image_config import ImageConfig
30
32
  from vacuum_map_parser_base.map_data import ImageData, MapData, Point
31
33
 
32
34
  from roborock.data.b01_q10.b01_q10_containers import Q10RoborockPoint
35
+ from roborock.data.code_mappings import RoborockEnum
33
36
  from roborock.data.containers import RoborockBase
34
37
  from roborock.exceptions import RoborockException
35
38
 
@@ -67,9 +70,6 @@ def classify_q10_cell(value: int) -> str:
67
70
  return LAYER_FLOOR
68
71
 
69
72
 
70
- MAP_PACKET_MARKER = b"\x01\x01"
71
- TRACE_PACKET_MARKER = b"\x02\x01"
72
-
73
73
  _MAP_ID_OFFSET = 2
74
74
  # Width and height are two consecutive u16be fields. An earlier revision read the
75
75
  # width as u16le at offset 8; that high byte is actually the height's high byte,
@@ -84,6 +84,7 @@ _LAYOUT_COMPRESSED_OFFSET = 29
84
84
  _ROOM_RECORD_LENGTH = 47
85
85
  _ROOM_NAME_LENGTH_OFFSET = 26
86
86
  _MAX_ROOMS = 32
87
+ _MAX_GRID_CELLS = 16_000_000
87
88
  # Sanity bound for the erase-zone vector section's vertices-per-polygon field.
88
89
  _MAX_ERASE_ZONE_VERTICES = 16
89
90
 
@@ -195,10 +196,34 @@ class Q10HeaderCalibration:
195
196
  )
196
197
 
197
198
 
199
+ class Q10MapPacketKind(RoborockEnum):
200
+ """Semantic kind identified by a Q10 map packet's two-byte marker."""
201
+
202
+ unknown = -1
203
+ CURRENT = 1
204
+ TRACE = 2
205
+ CLEAN_RECORD_DETAIL = 3
206
+ SAVED_MAP_DETAIL = 4
207
+
208
+ @property
209
+ def marker(self) -> bytes:
210
+ """Return the two-byte wire marker for this packet kind."""
211
+ return b"" if self is self.unknown else bytes((self.value, 1))
212
+
213
+ @classmethod
214
+ def from_payload(cls, payload: bytes) -> "Q10MapPacketKind | None":
215
+ """Return the recognized kind for a payload marker."""
216
+ if len(payload) < 2 or payload[1] != 1:
217
+ return None
218
+ kind = cls(payload[0])
219
+ return None if kind is cls.unknown else kind
220
+
221
+
198
222
  @dataclass
199
223
  class Q10MapPacket:
200
- """Decoded contents of a Q10 ``01 01`` map packet."""
224
+ """Decoded contents of a Q10 current or archived map packet."""
201
225
 
226
+ kind: Q10MapPacketKind
202
227
  map_id: int
203
228
  width: int
204
229
  height: int
@@ -271,6 +296,33 @@ class Q10TracePacket:
271
296
  return self.points[-1] if self.points else None
272
297
 
273
298
 
299
+ @dataclass
300
+ class Q10HistoricalTracePacket:
301
+ """Cleaning path embedded in a Q10 ``03 01`` clean-record detail packet.
302
+
303
+ This is a different wire layout from the live ``02 01`` trace. Its header
304
+ carries a 16-bit format version, a 32-bit opaque value, a 32-bit
305
+ point count, a signed heading, and a zero reserved word. Points use the same
306
+ signed big-endian ``(x, y)`` coordinate pairs as the live trace.
307
+ """
308
+
309
+ points: list[Q10Point] = field(default_factory=list)
310
+ heading: int = 0
311
+
312
+ @property
313
+ def robot_position(self) -> Q10Point | None:
314
+ """The final recorded position, if the historical path is non-empty."""
315
+ return self.points[-1] if self.points else None
316
+
317
+
318
+ @dataclass
319
+ class Q10CleanRecordDetail:
320
+ """A clean-record response containing a map and its recorded path."""
321
+
322
+ map: Q10MapPacket
323
+ trace: Q10HistoricalTracePacket | None = None
324
+
325
+
274
326
  # Trace packet (``02 01``): a 14-byte header followed by big-endian int16 (x, y)
275
327
  # point pairs forming the accumulated session path. Header layout confirmed
276
328
  # against live ss07 captures and cross-checked by @andrewlyeats:
@@ -280,18 +332,26 @@ class Q10TracePacket:
280
332
  # - bytes 10-11: the 0201 SLAM heading (s16be degrees; 0 = +x, +90 = +y,
281
333
  # +-180 = -x, -90 = -y) -- the robot's current orientation.
282
334
  # - bytes 12-13: a constant (0x0000).
283
- # - byte 14 onward: the path points.
335
+ # - byte 14 onward: exactly ``point_count`` path points.
284
336
  # An earlier revision used a 10-byte header, which folded the heading word into
285
337
  # a phantom leading point ``(heading, 0)`` -- that is the "stray point" the
286
338
  # heuristic below was papering over, and why the count read "one high". The
287
- # parser reads all 4-byte pairs in the body rather than trusting the count
288
- # field, so a truncated tail can't desync it.
339
+ # parser requires the declared point count to match the complete body, so a
340
+ # truncated or extended tail cannot be silently interpreted as path data.
289
341
  # NOTE: the format documented by roborock-qseries-map-bridge (18-byte header)
290
342
  # did not match this firmware -- this 14-byte layout is what the device sent.
291
343
  _TRACE_HEADER_LENGTH = 14
292
344
  _TRACE_SEQUENCE_OFFSET = 3
345
+ _TRACE_POINT_COUNT_OFFSET = 8
293
346
  _TRACE_HEADING_OFFSET = 10
294
347
 
348
+ _HISTORICAL_TRACE_HEADER_LENGTH = 14
349
+ _HISTORICAL_TRACE_PREFIX_LENGTH = 1
350
+ _HISTORICAL_TRACE_VERSION = 1
351
+ _HISTORICAL_TRACE_POINT_COUNT_OFFSET = 6
352
+ _HISTORICAL_TRACE_HEADING_OFFSET = 10
353
+ _HISTORICAL_TRACE_RESERVED_OFFSET = 12
354
+
295
355
  # Some cleans still prepend a single near-origin sentinel as the first real
296
356
  # point (e.g. ~(5, 76) / (-3, 0) when the path proper starts near (-1700, -800));
297
357
  # it skews the rendered start/bounding box and any path-based calibration. (This
@@ -306,12 +366,12 @@ _STRAY_POINT_STEP_RATIO = 20
306
366
 
307
367
  def is_map_packet(payload: bytes) -> bool:
308
368
  """Return True if the payload is a Q10 full-map (``01 01``) packet."""
309
- return payload[:2] == MAP_PACKET_MARKER
369
+ return Q10MapPacketKind.from_payload(payload) is Q10MapPacketKind.CURRENT
310
370
 
311
371
 
312
372
  def is_trace_packet(payload: bytes) -> bool:
313
373
  """Return True if the payload is a Q10 live trace (``02 01``) packet."""
314
- return payload[:2] == TRACE_PACKET_MARKER
374
+ return Q10MapPacketKind.from_payload(payload) is Q10MapPacketKind.TRACE
315
375
 
316
376
 
317
377
  def parse_trace_packet(payload: bytes) -> Q10TracePacket:
@@ -323,6 +383,9 @@ def parse_trace_packet(payload: bytes) -> Q10TracePacket:
323
383
  body = payload[_TRACE_HEADER_LENGTH:]
324
384
  if len(body) % 4:
325
385
  raise RoborockException("Q10 trace points are not 4-byte (x, y) pairs")
386
+ declared_point_count = int.from_bytes(payload[_TRACE_POINT_COUNT_OFFSET : _TRACE_POINT_COUNT_OFFSET + 2], "big")
387
+ if declared_point_count != len(body) // 4:
388
+ raise RoborockException("Q10 trace point count does not match its payload")
326
389
 
327
390
  heading = int.from_bytes(payload[_TRACE_HEADING_OFFSET : _TRACE_HEADING_OFFSET + 2], "big", signed=True)
328
391
  points = [
@@ -353,11 +416,13 @@ def _drop_stray_leading_point(points: list[Q10Point]) -> list[Q10Point]:
353
416
  return points
354
417
 
355
418
 
356
- def lz4_block_decompress(data: bytes) -> bytes:
419
+ def lz4_block_decompress(data: bytes, max_output_size: int) -> bytes:
357
420
  """Decompress a raw LZ4 *block* (no frame header).
358
421
 
359
422
  The Q10 map grid is stored as a single LZ4 block. This implements the
360
- standard LZ4 block format so we don't add a native dependency.
423
+ standard LZ4 block format so we don't add a native dependency. Expansion beyond
424
+ ``max_output_size`` is rejected before
425
+ allocating the excess output.
361
426
  """
362
427
  index = 0
363
428
  output = bytearray()
@@ -385,6 +450,8 @@ def lz4_block_decompress(data: bytes) -> bytes:
385
450
  end = index + literal_length
386
451
  if end > len(data):
387
452
  raise RoborockException("Truncated LZ4 block while reading literals")
453
+ if len(output) + literal_length > max_output_size:
454
+ raise RoborockException("LZ4 block exceeds maximum output size")
388
455
  output.extend(data[index:end])
389
456
  index = end
390
457
 
@@ -399,6 +466,8 @@ def lz4_block_decompress(data: bytes) -> bytes:
399
466
  raise RoborockException("Invalid LZ4 back-reference offset")
400
467
 
401
468
  match_length = read_length(token & 0x0F) + 4
469
+ if len(output) + match_length > max_output_size:
470
+ raise RoborockException("LZ4 block exceeds maximum output size")
402
471
  for _ in range(match_length):
403
472
  output.append(output[-offset])
404
473
 
@@ -463,8 +532,24 @@ def _parse_rooms(room_data: bytes, grid: bytes) -> list[Q10Room]:
463
532
 
464
533
 
465
534
  def parse_map_packet(payload: bytes) -> Q10MapPacket:
466
- """Parse a Q10 ``01 01`` map packet into grid + room metadata."""
467
- if len(payload) < _LAYOUT_COMPRESSED_OFFSET or not is_map_packet(payload):
535
+ """Parse the raster and shared metadata of a current or archived map."""
536
+ packet, _, _ = _parse_map_layout(payload)
537
+ return packet
538
+
539
+
540
+ def parse_clean_record_detail(payload: bytes) -> Q10CleanRecordDetail:
541
+ """Parse a clean-record response into its map and optional recorded path."""
542
+ if Q10MapPacketKind.from_payload(payload) is not Q10MapPacketKind.CLEAN_RECORD_DETAIL:
543
+ raise RoborockException("Payload is not a Q10 clean-record detail packet")
544
+ packet, tail, trace_offset = _parse_map_layout(payload)
545
+ trace = _parse_clean_record_trace(tail, trace_offset) if trace_offset is not None else None
546
+ return Q10CleanRecordDetail(map=packet, trace=trace)
547
+
548
+
549
+ def _parse_map_layout(payload: bytes) -> tuple[Q10MapPacket, bytes, int | None]:
550
+ """Decode shared map fields and return the tail and historical path offset."""
551
+ kind = Q10MapPacketKind.from_payload(payload)
552
+ if len(payload) < _LAYOUT_COMPRESSED_OFFSET or kind is None or kind is Q10MapPacketKind.TRACE:
468
553
  raise RoborockException("Payload is not a Q10 map packet")
469
554
 
470
555
  map_id = int.from_bytes(payload[_MAP_ID_OFFSET : _MAP_ID_OFFSET + 4], "big")
@@ -472,6 +557,8 @@ def parse_map_packet(payload: bytes) -> Q10MapPacket:
472
557
  height = int.from_bytes(payload[_HEIGHT_OFFSET : _HEIGHT_OFFSET + 2], "big")
473
558
  if width <= 0:
474
559
  raise RoborockException("Q10 map packet has invalid width")
560
+ if height > 0 and width * height > _MAX_GRID_CELLS:
561
+ raise RoborockException("Q10 map packet dimensions exceed the supported grid size")
475
562
 
476
563
  compressed_length = int.from_bytes(
477
564
  payload[_COMPRESSED_LAYOUT_LENGTH_OFFSET : _COMPRESSED_LAYOUT_LENGTH_OFFSET + 2], "big"
@@ -480,7 +567,10 @@ def parse_map_packet(payload: bytes) -> Q10MapPacket:
480
567
  if compressed_length <= 0 or layout_end > len(payload):
481
568
  raise RoborockException("Q10 map packet has invalid layout block length")
482
569
 
483
- decoded = lz4_block_decompress(payload[_LAYOUT_COMPRESSED_OFFSET:layout_end])
570
+ decoded = lz4_block_decompress(
571
+ payload[_LAYOUT_COMPRESSED_OFFSET:layout_end],
572
+ max_output_size=_MAX_GRID_CELLS + 2 + _MAX_ROOMS * _ROOM_RECORD_LENGTH,
573
+ )
484
574
  # Prefer the header height; fall back to inference if it doesn't line up
485
575
  # (e.g. older captures/fixtures that don't populate the height field).
486
576
  split = _split_with_dims(decoded, width, height) if height > 0 else None
@@ -491,9 +581,10 @@ def parse_map_packet(payload: bytes) -> Q10MapPacket:
491
581
  rooms = _parse_rooms(room_data, grid)
492
582
  tail = payload[layout_end:]
493
583
  erase_zones = _parse_erase_zones(tail)
494
- carpet_mask = _parse_carpet_mask(tail, width, height)
584
+ carpet_mask, carpet_end = _parse_carpet_block(tail, width, height)
495
585
  header_calibration = _parse_header_calibration(payload)
496
- return Q10MapPacket(
586
+ packet = Q10MapPacket(
587
+ kind=kind,
497
588
  map_id=map_id,
498
589
  width=width,
499
590
  height=height,
@@ -503,6 +594,7 @@ def parse_map_packet(payload: bytes) -> Q10MapPacket:
503
594
  header_calibration=header_calibration,
504
595
  carpet_mask=carpet_mask,
505
596
  )
597
+ return packet, tail, carpet_end
506
598
 
507
599
 
508
600
  def _parse_header_calibration(payload: bytes) -> Q10HeaderCalibration | None:
@@ -574,7 +666,18 @@ def _carpet_offset(tail: bytes) -> int:
574
666
  return 2 + count * vertices_per * 4
575
667
 
576
668
 
577
- def _parse_carpet_mask(tail: bytes, width: int, height: int) -> bytes | None:
669
+ def _erase_section_end(tail: bytes) -> int:
670
+ """Return the end of a complete, structurally valid erase section."""
671
+ if len(tail) < 2:
672
+ return 0
673
+ count, vertices_per = tail[0], tail[1]
674
+ if count and not 1 <= vertices_per <= _MAX_ERASE_ZONE_VERTICES:
675
+ return 0
676
+ end = _carpet_offset(tail)
677
+ return end if end <= len(tail) else 0
678
+
679
+
680
+ def _parse_carpet_block(tail: bytes, width: int, height: int) -> tuple[bytes | None, int | None]:
578
681
  """Decode the carpet mask that follows the erase section in the packet tail.
579
682
 
580
683
  Framing matches the main grid block: ``[u32 uncompressed_len]``
@@ -583,23 +686,73 @@ def _parse_carpet_mask(tail: bytes, width: int, height: int) -> bytes | None:
583
686
  non-zero cell is carpet (the value is the carpet kind). Confirmed byte-exact
584
687
  on live ss07 captures (R1 / RDC), where ``uncompressed_len == width*height``.
585
688
 
586
- Returns the decompressed mask, or ``None`` if the section is absent or does
587
- not line up (the ``uncompressed_len == width*height`` invariant is used as the
588
- guard so a mis-located section yields no carpet rather than garbage).
689
+ Returns the decompressed mask and its end offset. Both are ``None`` if the
690
+ section is absent or does not line up. The end offset is used to anchor
691
+ optional later sections without scanning arbitrary trailing bytes.
589
692
  """
590
- offset = _carpet_offset(tail)
693
+ offset = _erase_section_end(tail)
694
+ if offset == 0:
695
+ return None, None
591
696
  if offset + 6 > len(tail):
592
- return None
697
+ return None, None
593
698
  uncompressed_len = int.from_bytes(tail[offset : offset + 4], "big")
594
699
  compressed_len = int.from_bytes(tail[offset + 4 : offset + 6], "big")
595
700
  block_end = offset + 6 + compressed_len
596
701
  if uncompressed_len != width * height or compressed_len <= 0 or block_end > len(tail):
597
- return None
702
+ return None, None
598
703
  try:
599
- mask = lz4_block_decompress(tail[offset + 6 : block_end])
704
+ mask = lz4_block_decompress(tail[offset + 6 : block_end], max_output_size=width * height)
600
705
  except RoborockException:
706
+ return None, None
707
+ if len(mask) != width * height:
708
+ return None, None
709
+ return mask, block_end
710
+
711
+
712
+ def _parse_clean_record_trace(
713
+ tail: bytes,
714
+ offset: int,
715
+ ) -> Q10HistoricalTracePacket | None:
716
+ """Decode the bounded historical path following a ``03 01`` carpet block.
717
+
718
+ The header and declared point count were validated against a physical ss07
719
+ clean-record response and its point bytes match captured prefixes of the
720
+ corresponding live trace exactly. One observed zero byte precedes the path;
721
+ its meaning is unknown, so a non-zero value makes the entire section opaque.
722
+ Any unsupported version, non-zero reserved word, or truncated point table is
723
+ likewise left completely opaque. Bytes after the declared points are
724
+ deliberately not consumed: the observed 12-byte suffix appears structured,
725
+ but there is not enough controlled evidence to name or decode it safely.
726
+ """
727
+ if offset >= len(tail) or tail[offset] != 0:
728
+ return None
729
+ offset += _HISTORICAL_TRACE_PREFIX_LENGTH
730
+ header_end = offset + _HISTORICAL_TRACE_HEADER_LENGTH
731
+ if header_end > len(tail):
601
732
  return None
602
- return mask if len(mask) == width * height else None
733
+ version = int.from_bytes(tail[offset : offset + 2], "big")
734
+ reserved = int.from_bytes(
735
+ tail[offset + _HISTORICAL_TRACE_RESERVED_OFFSET : offset + _HISTORICAL_TRACE_RESERVED_OFFSET + 2],
736
+ "big",
737
+ )
738
+ if version != _HISTORICAL_TRACE_VERSION or reserved != 0:
739
+ return None
740
+ point_count = int.from_bytes(
741
+ tail[offset + _HISTORICAL_TRACE_POINT_COUNT_OFFSET : offset + _HISTORICAL_TRACE_POINT_COUNT_OFFSET + 4],
742
+ "big",
743
+ )
744
+ points_end = header_end + point_count * 4
745
+ if points_end > len(tail):
746
+ return None
747
+ coordinates = struct.iter_unpack(">hh", memoryview(tail)[header_end:points_end])
748
+ return Q10HistoricalTracePacket(
749
+ points=_drop_stray_leading_point([Q10Point(x=x, y=y) for x, y in coordinates]),
750
+ heading=int.from_bytes(
751
+ tail[offset + _HISTORICAL_TRACE_HEADING_OFFSET : offset + _HISTORICAL_TRACE_HEADING_OFFSET + 2],
752
+ "big",
753
+ signed=True,
754
+ ),
755
+ )
603
756
 
604
757
 
605
758
  def erased_packet(packet: "Q10MapPacket", cells: set[int]) -> "Q10MapPacket":
@@ -34,6 +34,7 @@ from .b01_q10_map_parser import (
34
34
  B01Q10MapParser,
35
35
  B01Q10MapParserConfig,
36
36
  Q10EraseZone,
37
+ Q10HistoricalTracePacket,
37
38
  Q10MapPacket,
38
39
  Q10TracePacket,
39
40
  erased_packet,
@@ -62,7 +63,6 @@ _MIN_CALIBRATION_POINTS = 20
62
63
  # a much shorter path suffices to confirm it (early in a clean, not just a dense
63
64
  # one). See :func:`solve_calibration_with_origin`.
64
65
  _MIN_HEADER_CALIBRATION_POINTS = 4
65
-
66
66
  _Q10_DRAWABLE_TYPES = {
67
67
  Drawable.CHARGER,
68
68
  Drawable.NO_GO_AREAS,
@@ -84,7 +84,7 @@ class Q10MapOverlays:
84
84
 
85
85
  def render_q10_map(
86
86
  packet: Q10MapPacket,
87
- trace: Q10TracePacket | None,
87
+ trace: Q10TracePacket | Q10HistoricalTracePacket | None,
88
88
  overlays: Q10MapOverlays,
89
89
  *,
90
90
  config: B01Q10MapParserConfig,
@@ -133,7 +133,7 @@ def render_q10_map(
133
133
 
134
134
  def solve_q10_calibration(
135
135
  packet: Q10MapPacket,
136
- trace: Q10TracePacket | None,
136
+ trace: Q10TracePacket | Q10HistoricalTracePacket | None,
137
137
  ) -> GridCalibration | None:
138
138
  """Derive world-to-pixel calibration from a map and its current trace.
139
139
 
@@ -232,7 +232,7 @@ def _erased_cells(
232
232
  def _place_trace(
233
233
  map_data: MapData,
234
234
  calibration: GridCalibration,
235
- trace: Q10TracePacket,
235
+ trace: Q10TracePacket | Q10HistoricalTracePacket,
236
236
  *,
237
237
  charger_heading: int | None = None,
238
238
  ) -> None:
@@ -11,10 +11,11 @@ from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
11
11
  from roborock.data.b01_q10.b01_q10_containers import Q10RoborockPoint
12
12
  from roborock.exceptions import RoborockException
13
13
  from roborock.map.b01_q10_map_parser import (
14
+ Q10CleanRecordDetail,
14
15
  Q10MapPacket,
16
+ Q10MapPacketKind,
15
17
  Q10TracePacket,
16
- is_map_packet,
17
- is_trace_packet,
18
+ parse_clean_record_detail,
18
19
  parse_map_packet,
19
20
  parse_trace_packet,
20
21
  )
@@ -155,24 +156,27 @@ class Q10DpsUpdate:
155
156
  # A single decoded message from a Q10 device: a DPS status update, a full map
156
157
  # packet, or a live cleaning-path (trace) packet. Map/trace packets arrive as
157
158
  # protocol-301 ``MAP_RESPONSE`` pushes; everything else is a DPS update.
158
- Q10Message = Q10DpsUpdate | Q10MapPacket | Q10TracePacket
159
+ Q10Message = Q10DpsUpdate | Q10MapPacket | Q10TracePacket | Q10CleanRecordDetail
159
160
 
160
161
 
161
162
  def decode_message(message: RoborockMessage) -> Q10Message | None:
162
163
  """Decode a pushed Q10 ``RoborockMessage`` into a typed message.
163
164
 
164
- ``MAP_RESPONSE`` (protocol 301) payloads carry the binary map (``01 01``) or
165
- trace (``02 01``) packets, which are parsed by the map parser; any other
166
- ``MAP_RESPONSE`` marker is unrecognized and yields ``None``. Every other
167
- protocol is treated as a DPS status update.
165
+ ``MAP_RESPONSE`` (protocol 301) payloads carry binary current-map (``01
166
+ 01``), trace (``02 01``), clean-record detail (``03 01``), or saved-map
167
+ detail (``04 01``) packets. Any other marker is unrecognized and yields
168
+ ``None``. Every other protocol is treated as a DPS status update.
168
169
 
169
170
  Raises ``RoborockException`` if a recognized payload fails to parse.
170
171
  """
171
172
  if message.protocol == RoborockMessageProtocol.MAP_RESPONSE:
172
173
  payload = message.payload or b""
173
- if is_map_packet(payload):
174
- return parse_map_packet(payload)
175
- if is_trace_packet(payload):
174
+ kind = Q10MapPacketKind.from_payload(payload)
175
+ if kind is Q10MapPacketKind.TRACE:
176
176
  return parse_trace_packet(payload)
177
+ if kind is Q10MapPacketKind.CLEAN_RECORD_DETAIL:
178
+ return parse_clean_record_detail(payload)
179
+ if kind is not None:
180
+ return parse_map_packet(payload)
177
181
  return None
178
182
  return Q10DpsUpdate(dps=decode_rpc_response(message))