python-roborock 6.2.0__tar.gz → 6.2.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 (128) hide show
  1. {python_roborock-6.2.0 → python_roborock-6.2.1}/PKG-INFO +1 -1
  2. {python_roborock-6.2.0 → python_roborock-6.2.1}/pyproject.toml +1 -1
  3. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/b01/q10/map.py +19 -11
  4. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/common.py +6 -2
  5. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/map/b01_q10_map_parser.py +29 -9
  6. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/map/b01_q10_overlays.py +6 -5
  7. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/map/b01_q10_render.py +126 -77
  8. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/map/map_parser.py +48 -17
  9. {python_roborock-6.2.0 → python_roborock-6.2.1}/.gitignore +0 -0
  10. {python_roborock-6.2.0 → python_roborock-6.2.1}/LICENSE +0 -0
  11. {python_roborock-6.2.0 → python_roborock-6.2.1}/README.md +0 -0
  12. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/__init__.py +0 -0
  13. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/broadcast_protocol.py +0 -0
  14. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/callbacks.py +0 -0
  15. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/cli.py +0 -0
  16. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/const.py +0 -0
  17. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/data/__init__.py +0 -0
  18. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/data/b01_q10/__init__.py +0 -0
  19. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
  20. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
  21. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/data/b01_q7/__init__.py +0 -0
  22. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  23. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  24. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/data/code_mappings.py +0 -0
  25. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/data/containers.py +0 -0
  26. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/data/dyad/__init__.py +0 -0
  27. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  28. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/data/dyad/dyad_containers.py +0 -0
  29. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/data/mower/__init__.py +0 -0
  30. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/data/mower/mower_containers.py +0 -0
  31. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/data/v1/__init__.py +0 -0
  32. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/data/v1/v1_clean_modes.py +0 -0
  33. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/data/v1/v1_code_mappings.py +0 -0
  34. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/data/v1/v1_containers.py +0 -0
  35. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/data/zeo/__init__.py +0 -0
  36. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  37. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/data/zeo/zeo_containers.py +0 -0
  38. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/device_features.py +0 -0
  39. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/README.md +0 -0
  40. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/__init__.py +0 -0
  41. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/cache.py +0 -0
  42. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/device.py +0 -0
  43. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/device_manager.py +0 -0
  44. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/file_cache.py +0 -0
  45. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/rpc/__init__.py +0 -0
  46. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/rpc/a01_channel.py +0 -0
  47. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/rpc/b01_q10_channel.py +0 -0
  48. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/rpc/b01_q7_channel.py +0 -0
  49. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/rpc/v1_channel.py +0 -0
  50. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/__init__.py +0 -0
  51. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/a01/__init__.py +0 -0
  52. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/b01/__init__.py +0 -0
  53. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/b01/q10/__init__.py +0 -0
  54. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/b01/q10/button_light.py +0 -0
  55. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/b01/q10/child_lock.py +0 -0
  56. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/b01/q10/clean_history.py +0 -0
  57. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/b01/q10/command.py +0 -0
  58. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/b01/q10/common.py +0 -0
  59. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/b01/q10/consumable.py +0 -0
  60. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/b01/q10/do_not_disturb.py +0 -0
  61. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/b01/q10/dust_collection.py +0 -0
  62. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/b01/q10/network_info.py +0 -0
  63. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/b01/q10/remote.py +0 -0
  64. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/b01/q10/status.py +0 -0
  65. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
  66. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/b01/q10/volume.py +0 -0
  67. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/b01/q7/__init__.py +0 -0
  68. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
  69. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/b01/q7/map.py +0 -0
  70. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/b01/q7/map_content.py +0 -0
  71. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/traits_mixin.py +0 -0
  72. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/v1/__init__.py +0 -0
  73. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/v1/child_lock.py +0 -0
  74. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/v1/clean_summary.py +0 -0
  75. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/v1/command.py +0 -0
  76. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/v1/common.py +0 -0
  77. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/v1/consumeable.py +0 -0
  78. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/v1/device_features.py +0 -0
  79. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  80. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  81. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  82. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/v1/home.py +0 -0
  83. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/v1/led_status.py +0 -0
  84. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/v1/map_content.py +0 -0
  85. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/v1/maps.py +0 -0
  86. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/v1/network_info.py +0 -0
  87. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/v1/obstacle_photos.py +0 -0
  88. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/v1/rooms.py +0 -0
  89. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/v1/routines.py +0 -0
  90. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  91. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/v1/status.py +0 -0
  92. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  93. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/v1/volume.py +0 -0
  94. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  95. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/transport/__init__.py +0 -0
  96. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/transport/channel.py +0 -0
  97. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/transport/local_channel.py +0 -0
  98. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/devices/transport/mqtt_channel.py +0 -0
  99. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/diagnostics.py +0 -0
  100. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/exceptions.py +0 -0
  101. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/map/__init__.py +0 -0
  102. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/map/b01_grid_layers.py +0 -0
  103. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/map/b01_map_parser.py +0 -0
  104. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/map/proto/__init__.py +0 -0
  105. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/map/proto/b01_scmap.proto +0 -0
  106. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/map/proto/b01_scmap_pb2.py +0 -0
  107. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/map/room_colors.py +0 -0
  108. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/mqtt/__init__.py +0 -0
  109. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/mqtt/health_manager.py +0 -0
  110. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/mqtt/roborock_session.py +0 -0
  111. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/mqtt/session.py +0 -0
  112. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/protocol.py +0 -0
  113. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/protocols/__init__.py +0 -0
  114. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/protocols/a01_protocol.py +0 -0
  115. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/protocols/b01_q10_protocol.py +0 -0
  116. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/protocols/b01_q7_protocol.py +0 -0
  117. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/protocols/v1_protocol.py +0 -0
  118. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/py.typed +0 -0
  119. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/roborock_message.py +0 -0
  120. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/roborock_typing.py +0 -0
  121. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/testing/__init__.py +0 -0
  122. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/testing/b01_q10_simulator.py +0 -0
  123. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/testing/channel.py +0 -0
  124. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/testing/cloud.py +0 -0
  125. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/testing/simulator.py +0 -0
  126. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/testing/v1_simulator.py +0 -0
  127. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/util.py +0 -0
  128. {python_roborock-6.2.0 → python_roborock-6.2.1}/roborock/web_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-roborock
3
- Version: 6.2.0
3
+ Version: 6.2.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 = "6.2.0"
3
+ version = "6.2.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"
@@ -4,12 +4,12 @@ Map-related state arrives on three independent streams:
4
4
 
5
5
  * map packets are decoded from map-protocol responses;
6
6
  * trace packets are decoded from trace-protocol responses;
7
- * restricted zones and virtual walls arrive as ordinary DPS values.
7
+ * restricted zones, virtual walls and dock state arrive as ordinary DPS values.
8
8
 
9
- ``MapDpsTrait`` owns the low-level DPS read model. ``MapContentTrait`` depends
10
- on it and combines that state with the latest map/trace packets through the pure
11
- functions in :mod:`roborock.map.b01_q10_render`. The high-level trait keeps only
12
- the latest value from each source and one replace-whole rendered image;
9
+ ``MapDpsTrait`` owns the low-level map-specific DPS read model.
10
+ ``MapContentTrait`` combines that state with the latest map/trace packets
11
+ through the pure functions in :mod:`roborock.map.b01_q10_render`. The high-level
12
+ trait keeps only the latest value from each source and one replace-whole image;
13
13
  calibration, path placement and overlay placement remain inside the renderer.
14
14
  """
15
15
 
@@ -18,7 +18,7 @@ from dataclasses import dataclass, field
18
18
  from typing import Any
19
19
 
20
20
  from roborock.data import RoborockBase
21
- from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
21
+ from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXDeviceState
22
22
  from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener
23
23
  from roborock.exceptions import RoborockException
24
24
  from roborock.map.b01_q10_map_parser import (
@@ -34,12 +34,14 @@ from roborock.map.b01_q10_render import Q10MapOverlays, render_q10_map
34
34
  from .common import UpdatableTrait
35
35
 
36
36
  _LOGGER = logging.getLogger(__name__)
37
+ _DOCKED_STATES = {YXDeviceState.CHARGING, YXDeviceState.EMPTYING_THE_BIN}
37
38
 
38
39
 
39
40
  @dataclass
40
41
  class MapDps(RoborockBase):
41
42
  """Low-level map values delivered in the Q10 DPS stream."""
42
43
 
44
+ status: YXDeviceState | None = field(default=None, metadata={"dps": B01_Q10_DP.STATUS})
43
45
  restricted_zone_up: str | None = field(default=None, metadata={"dps": B01_Q10_DP.RESTRICTED_ZONE_UP})
44
46
  virtual_wall_up: str | None = field(default=None, metadata={"dps": B01_Q10_DP.VIRTUAL_WALL_UP})
45
47
 
@@ -59,8 +61,13 @@ class MapDpsTrait(MapDps, UpdatableTrait):
59
61
  """Overlays decoded once from the latest relevant DPS update."""
60
62
  return self._overlays
61
63
 
64
+ @property
65
+ def robot_at_dock(self) -> bool:
66
+ """Whether status places the idle robot at the saved dock."""
67
+ return self.status in _DOCKED_STATES
68
+
62
69
  def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
63
- """Decode overlay blobs when they arrive, then notify dependents."""
70
+ """Update one coherent snapshot of the DPS inputs used by the map."""
64
71
  if not self._CONVERTER.update_from_dps(self, decoded_dps):
65
72
  return
66
73
  self._overlays = Q10MapOverlays(
@@ -73,8 +80,8 @@ class MapDpsTrait(MapDps, UpdatableTrait):
73
80
  class MapContentTrait(TraitUpdateListener):
74
81
  """High-level composed Q10 map view.
75
82
 
76
- The latest map and trace packets are combined with the injected
77
- :class:`MapDpsTrait` whenever any of those three sources changes.
83
+ The latest map and trace packets are combined with the injected map DPS
84
+ whenever any source changes.
78
85
  """
79
86
 
80
87
  def __init__(
@@ -129,7 +136,7 @@ class MapContentTrait(TraitUpdateListener):
129
136
  self._notify_update()
130
137
 
131
138
  def _map_dps_updated(self) -> None:
132
- """Render after the low-level DPS source changes."""
139
+ """Render after the low-level map DPS source changes."""
133
140
  if self._map_packet is None:
134
141
  return
135
142
  self._render()
@@ -142,9 +149,10 @@ class MapContentTrait(TraitUpdateListener):
142
149
  try:
143
150
  self._image_content = render_q10_map(
144
151
  self._map_packet,
145
- self._trace_packet,
152
+ self._trace_packet if not self._map_dps.robot_at_dock else None,
146
153
  self._map_dps.overlays,
147
154
  config=self._config,
155
+ robot_at_dock=self._map_dps.robot_at_dock,
148
156
  )
149
157
  except RoborockException as ex:
150
158
  _LOGGER.debug("Failed to render Q10 map packet: %s", ex)
@@ -105,10 +105,14 @@ class DpsDataConverter(Generic[TDps]):
105
105
  decoded_dps: The decoded DPS data to convert.
106
106
 
107
107
  Returns:
108
- True if any values were updated, False otherwise.
108
+ True if any values changed, False otherwise.
109
109
  """
110
110
  conversions = RoborockBase.convert_dict(self._dps_type_map, decoded_dps)
111
+ changed = False
111
112
  for dps_id, value in conversions.items():
112
113
  field_name = self._dps_field_map[dps_id]
114
+ if getattr(target, field_name) == value:
115
+ continue
113
116
  setattr(target, field_name, value)
114
- return bool(conversions)
117
+ changed = True
118
+ return changed
@@ -27,7 +27,7 @@ from dataclasses import dataclass, field, replace
27
27
  from PIL import Image
28
28
  from vacuum_map_parser_base.config.color import ColorsPalette, SupportedColor
29
29
  from vacuum_map_parser_base.config.image_config import ImageConfig
30
- from vacuum_map_parser_base.map_data import ImageData, MapData
30
+ from vacuum_map_parser_base.map_data import ImageData, MapData, Point
31
31
 
32
32
  from roborock.exceptions import RoborockException
33
33
 
@@ -94,15 +94,18 @@ _MAX_ERASE_ZONE_VERTICES = 16
94
94
  # 50 mm/px, so dividing by 10 yields the origin in grid pixels -- the (ox, oy)
95
95
  # that solve_calibration otherwise recovers by sliding the path.
96
96
  # - 15-16 resolution (u16be): reads 5 (= 0.05 m/px = 50 mm/px) universally.
97
- # - 17-18 charger x, 19-20 charger y (s16be, 5 mm units), 21-22 charger phi.
97
+ # - 17-18 charger x, 19-20 charger y (s16be, absolute map decipixels),
98
+ # 21-22 charger phi. The official app's device-point transform confirms these
99
+ # coordinates are already in the map-array frame and must not receive x_min /
100
+ # y_min a second time.
98
101
  _ORIGIN_X_OFFSET = 11
99
102
  _ORIGIN_Y_OFFSET = 13
100
103
  _HEADER_RESOLUTION_OFFSET = 15
101
104
  _CHARGER_X_OFFSET = 17
102
105
  _CHARGER_Y_OFFSET = 19
103
106
  _CHARGER_PHI_OFFSET = 21
104
- # The header origin/charger are in 5 mm units and the grid is 50 mm/px, so a
105
- # header coordinate maps to grid pixels by dividing by this.
107
+ # The header origin and charger fields both divide by 10 to reach grid pixels,
108
+ # although they use different coordinate frames as documented above.
106
109
  _HEADER_UNITS_PER_PIXEL = 10
107
110
 
108
111
  # Grid cell values >= this are walls / borders rather than room segments.
@@ -130,7 +133,8 @@ class Q10EraseZone:
130
133
 
131
134
  These are the app's *Erase* tool rectangles -- regions the user marked to be
132
135
  removed from the map (e.g. phantom floor the lidar mapped through windows).
133
- Coordinates are world units (millimetres), same frame as the path/zones.
136
+ Coordinates are 5 mm world units, the same frame as restriction zones.
137
+ Trace points use a separate 2.5 mm scale.
134
138
 
135
139
  Confirmed by a controlled diff: removing the two erase zones on a live device
136
140
  dropped this section's count from 2 to 0 while the grid and the trailing
@@ -149,9 +153,11 @@ class Q10HeaderCalibration:
149
153
  straight from the map packet -- no cleaning path / fit required, so it works
150
154
  docked or pre-clean. See :meth:`origin_pixels`.
151
155
 
152
- ``origin_x`` / ``origin_y`` and the charger coordinates are in 5 mm units;
153
- ``resolution`` is the raw header field (5 == 50 mm/px). ``charger_phi`` is the
154
- raw dock heading field. Reported and verified by @andrewlyeats (ss07).
156
+ ``origin_x`` / ``origin_y`` are in 5 mm units and define the world-coordinate
157
+ origin. The charger coordinates are absolute decipixels in the map-array
158
+ frame, so they are divided by 10 without applying that origin again.
159
+ ``resolution`` is the raw header field (5 == 50 mm/px). ``charger_phi`` is
160
+ the raw dock heading field. Reported and verified by @andrewlyeats (ss07).
155
161
  """
156
162
 
157
163
  origin_x: int
@@ -177,6 +183,15 @@ class Q10HeaderCalibration:
177
183
  return None
178
184
  return (self.origin_x / _HEADER_UNITS_PER_PIXEL, self.origin_y / _HEADER_UNITS_PER_PIXEL)
179
185
 
186
+ def charger_pixels(self) -> tuple[float, float] | None:
187
+ """Return the saved dock in absolute map-array pixel coordinates."""
188
+ if (self.charger_x == 0 and self.charger_y == 0) or self.charger_x == -1 or self.charger_y == -1:
189
+ return None
190
+ return (
191
+ self.charger_x / _HEADER_UNITS_PER_PIXEL,
192
+ self.charger_y / _HEADER_UNITS_PER_PIXEL,
193
+ )
194
+
180
195
 
181
196
  @dataclass
182
197
  class Q10MapPacket:
@@ -640,7 +655,12 @@ class B01Q10MapParser:
640
655
  width=packet.width,
641
656
  image_config=ImageConfig(scale=self._config.map_scale),
642
657
  data=image,
643
- img_transformation=lambda p: p,
658
+ # ImageDimensions uses V1's bottom-up map convention before
659
+ # projecting into the top-down PNG. Q10 points are already
660
+ # top-down grid pixels, so this adapter cancels that standard flip
661
+ # and lets Q10 use the shared V1 ImageGenerator without moving
662
+ # overlays vertically.
663
+ img_transformation=lambda p: Point(p.x, packet.height - p.y - 1, p.a),
644
664
  )
645
665
  room_names = {room.id: room.name for room in packet.rooms}
646
666
  if room_names:
@@ -19,11 +19,12 @@ the next, a coordinate, as a record count). The coordinate order matches the
19
19
  zones (first wire word = x), confirmed against the app. Provenance and the
20
20
  byte-level breakdown are in PR #850's review thread.
21
21
 
22
- Coordinates are in the device's world units (the same space as the cleaning
23
- path), so a :class:`~roborock.map.b01_grid_layers.GridCalibration` maps them to
24
- map pixels. ``type`` distinguishes the restriction kind (2 = no-mop, 3 = door
25
- threshold, anything else -- incl. 0 -- a no-go zone); it is preserved verbatim
26
- so callers can route polygons to the right ``MapData`` layer.
22
+ Coordinates are in 5 mm device world units, the same space as erase polygons.
23
+ Cleaning trace points use a separate 2.5 mm scale, so callers must not project
24
+ both through the same resolution. ``type`` distinguishes the restriction kind
25
+ (2 = no-mop, 3 = door threshold, anything else -- incl. 0 -- a no-go zone); it
26
+ is preserved verbatim so callers can route polygons to the right ``MapData``
27
+ layer.
27
28
  """
28
29
 
29
30
  import base64
@@ -18,7 +18,8 @@ import math
18
18
  from collections.abc import Sequence
19
19
  from dataclasses import dataclass
20
20
 
21
- from PIL import Image, ImageDraw
21
+ from vacuum_map_parser_base.config.drawable import Drawable
22
+ from vacuum_map_parser_base.config.size import Size, Sizes
22
23
  from vacuum_map_parser_base.map_data import Area, MapData, Path, Point, Wall
23
24
 
24
25
  from roborock.exceptions import RoborockException
@@ -38,6 +39,7 @@ from .b01_q10_map_parser import (
38
39
  erased_packet,
39
40
  )
40
41
  from .b01_q10_overlays import ZONE_TYPE_NO_GO, ZONE_TYPE_NO_MOP, Q10Zone
42
+ from .map_parser import DEFAULT_DRAWABLES, MapParserConfig, _create_image_generator
41
43
 
42
44
  # Path-units-per-pixel candidates for calibration. A dense ss07 path lands a
43
45
  # best fit of 20.0 around the header origin -- ground-truthed June 2026 on the
@@ -49,6 +51,10 @@ from .b01_q10_overlays import ZONE_TYPE_NO_GO, ZONE_TYPE_NO_MOP, Q10Zone
49
51
  # reach 20 (it railed at the bound), biasing the fit. A dense cleaning path
50
52
  # selects the best fit within this bracket.
51
53
  _Q10_RESOLUTIONS = [step * 0.5 for step in range(24, 53)] # 12.0 .. 26.0
54
+ # The header resolution is expressed in centimetres per grid pixel, while
55
+ # erase/restriction vectors use 5 mm units: one header unit therefore equals
56
+ # two vector units. Trace points use a separate 2.5 mm coordinate scale.
57
+ _Q10_VECTOR_UNITS_PER_HEADER_RESOLUTION_UNIT = 2.0
52
58
  # A path needs enough shape to constrain a full (origin + resolution) fit; a few
53
59
  # points cannot.
54
60
  _MIN_CALIBRATION_POINTS = 20
@@ -57,6 +63,16 @@ _MIN_CALIBRATION_POINTS = 20
57
63
  # one). See :func:`solve_calibration_with_origin`.
58
64
  _MIN_HEADER_CALIBRATION_POINTS = 4
59
65
 
66
+ _Q10_DRAWABLE_TYPES = {
67
+ Drawable.CHARGER,
68
+ Drawable.NO_GO_AREAS,
69
+ Drawable.NO_MOPPING_AREAS,
70
+ Drawable.PATH,
71
+ Drawable.VACUUM_POSITION,
72
+ Drawable.VIRTUAL_WALLS,
73
+ }
74
+ _Q10_DRAWABLES = [drawable for drawable in DEFAULT_DRAWABLES if drawable in _Q10_DRAWABLE_TYPES]
75
+
60
76
 
61
77
  @dataclass(frozen=True)
62
78
  class Q10MapOverlays:
@@ -72,21 +88,22 @@ def render_q10_map(
72
88
  overlays: Q10MapOverlays,
73
89
  *,
74
90
  config: B01Q10MapParserConfig,
91
+ robot_at_dock: bool = False,
75
92
  ) -> bytes:
76
93
  """Compose the latest map, trace and DPS inputs into one PNG image.
77
94
 
78
- Calibration is derived from ``packet`` (layers + header calibration) and
79
- ``trace`` (path points). Once calibrated, erase zones are blanked out of the
80
- raster and trace/overlay data is projected and drawn in pixel space.
81
- Without a usable trace only the base raster is rendered. Raises
82
- :class:`RoborockException` if map rendering fails.
95
+ Separate transforms are derived for 2.5 mm trace points and 5 mm
96
+ erase/restriction vectors. Erase zones are blanked out of the raster, while
97
+ available trace and DPS overlays are projected and drawn in pixel space.
98
+ Raises :class:`RoborockException` if map rendering fails.
83
99
  """
84
100
  parser = B01Q10MapParser(config)
85
- calibration = solve_q10_calibration(packet, trace)
101
+ trace_calibration = solve_q10_calibration(packet, trace)
102
+ vector_calibration = _vector_calibration(packet, trace_calibration)
86
103
 
87
104
  render_packet = packet
88
- if calibration is not None:
89
- cells = _erased_cells(packet.layers, packet.erase_zones, calibration)
105
+ if vector_calibration is not None:
106
+ cells = _erased_cells(packet.layers, packet.erase_zones, vector_calibration)
90
107
  if cells:
91
108
  # Blank the erase-zone cells before parsing the raster so phantom
92
109
  # areas disappear (as the app shows).
@@ -97,10 +114,19 @@ def render_q10_map(
97
114
  raise RoborockException("Failed to render Q10 map image")
98
115
  map_data = parsed.map_data
99
116
 
100
- if calibration is not None and trace is not None:
101
- _place_trace(map_data, calibration, trace)
102
- _place_overlays(map_data, calibration, overlays)
103
- return _draw_map_content(parsed.image_content, map_data, config=config)
117
+ has_drawables = False
118
+ if trace_calibration is not None and trace is not None:
119
+ charger_heading = packet.header_calibration.charger_phi if packet.header_calibration is not None else None
120
+ _place_trace(map_data, trace_calibration, trace, charger_heading=charger_heading)
121
+ has_drawables = True
122
+ has_drawables = _place_charger_from_header(map_data, packet) or has_drawables
123
+ if robot_at_dock:
124
+ has_drawables = _place_docked_robot(map_data) or has_drawables
125
+ if vector_calibration is not None:
126
+ _place_overlays(map_data, vector_calibration, overlays)
127
+ has_drawables = has_drawables or bool(map_data.no_go_areas or map_data.no_mopping_areas or map_data.walls)
128
+ if has_drawables:
129
+ return _draw_map_content(map_data, config=config)
104
130
 
105
131
  return parsed.image_content
106
132
 
@@ -137,6 +163,41 @@ def _calibration_from_header(
137
163
  return solve_calibration_with_origin(packet.layers, points, origin, resolutions=_Q10_RESOLUTIONS)
138
164
 
139
165
 
166
+ def _calibration_from_header_metadata(
167
+ packet: Q10MapPacket,
168
+ *,
169
+ y_sign: int = 1,
170
+ ) -> GridCalibration | None:
171
+ """Build the vector transform directly from a usable map header."""
172
+ header = packet.header_calibration
173
+ if header is None or header.resolution <= 0 or (origin := header.origin_pixels()) is None:
174
+ return None
175
+ return GridCalibration(
176
+ resolution=header.resolution * _Q10_VECTOR_UNITS_PER_HEADER_RESOLUTION_UNIT,
177
+ origin_x=origin[0],
178
+ origin_y=origin[1],
179
+ y_sign=y_sign,
180
+ )
181
+
182
+
183
+ def _vector_calibration(
184
+ packet: Q10MapPacket,
185
+ trace_calibration: GridCalibration | None,
186
+ ) -> GridCalibration | None:
187
+ """Derive the 5 mm erase/restriction-vector transform."""
188
+ y_sign = trace_calibration.y_sign if trace_calibration is not None else 1
189
+ if calibration := _calibration_from_header_metadata(packet, y_sign=y_sign):
190
+ return calibration
191
+ if trace_calibration is None:
192
+ return None
193
+ return GridCalibration(
194
+ resolution=trace_calibration.resolution / 2,
195
+ origin_x=trace_calibration.origin_x,
196
+ origin_y=trace_calibration.origin_y,
197
+ y_sign=trace_calibration.y_sign,
198
+ )
199
+
200
+
140
201
  def _calibration_from_fit(layers: GridLayers, points: list[tuple[float, float]]) -> GridCalibration | None:
141
202
  """Full origin + resolution fit; needs a reasonably dense path."""
142
203
  if len(points) < _MIN_CALIBRATION_POINTS:
@@ -170,6 +231,8 @@ def _place_trace(
170
231
  map_data: MapData,
171
232
  calibration: GridCalibration,
172
233
  trace: Q10TracePacket,
234
+ *,
235
+ charger_heading: int | None = None,
173
236
  ) -> None:
174
237
  """Project trace path, position, heading and charger into pixel space.
175
238
 
@@ -181,11 +244,48 @@ def _place_trace(
181
244
  robot_position = trace.robot_position
182
245
  if robot_position is not None:
183
246
  px, py = calibration.world_to_pixel(robot_position.x, robot_position.y)
184
- # Store the heading in projected image coordinates so drawing does not
185
- # need to retain the world-to-pixel calibration.
186
- map_data.vacuum_position = Point(px, py, -calibration.y_sign * trace.heading)
247
+ # The shared V1 marker expects a map-space heading (+Y is up), not the
248
+ # top-down PNG angle previously used by Q10's custom marker.
249
+ map_data.vacuum_position = Point(px, py, calibration.y_sign * trace.heading)
187
250
  if pixels:
188
- map_data.charger = pixels[0]
251
+ map_data.charger = Point(
252
+ pixels[0].x,
253
+ pixels[0].y,
254
+ calibration.y_sign * charger_heading if charger_heading is not None else None,
255
+ )
256
+
257
+
258
+ def _place_charger_from_header(
259
+ map_data: MapData,
260
+ packet: Q10MapPacket,
261
+ ) -> bool:
262
+ """Place the saved dock using its absolute header pixel coordinates."""
263
+ header = packet.header_calibration
264
+ if header is None or (position := header.charger_pixels()) is None:
265
+ return False
266
+ map_data.charger = Point(*position, header.charger_phi)
267
+ return True
268
+
269
+
270
+ def _place_docked_robot(map_data: MapData) -> bool:
271
+ """Place a charging robot immediately in front of the saved dock.
272
+
273
+ A zero-point idle trace has no robot coordinates. The dock heading does,
274
+ however, identify its outward-facing side. Offset the robot by the shared
275
+ unscaled V1 charger radius so the two standard glyphs meet without one
276
+ covering the other, and preserve the saved dock heading.
277
+ """
278
+ charger = map_data.charger
279
+ if charger is None or charger.a is None:
280
+ return False
281
+ angle = math.radians(charger.a)
282
+ offset = Sizes.SIZES[Size.CHARGER_RADIUS]
283
+ map_data.vacuum_position = Point(
284
+ charger.x + offset * math.cos(angle),
285
+ charger.y - offset * math.sin(angle),
286
+ charger.a,
287
+ )
288
+ return True
189
289
 
190
290
 
191
291
  def _place_overlays(
@@ -217,69 +317,18 @@ def _place_overlays(
217
317
 
218
318
 
219
319
  def _draw_map_content(
220
- image_content: bytes,
221
320
  map_data: MapData,
222
321
  *,
223
322
  config: B01Q10MapParserConfig,
224
- line_color: tuple[int, int, int, int] = (235, 64, 52, 255),
225
- position_color: tuple[int, int, int, int] = (255, 211, 0, 255),
226
323
  ) -> bytes:
227
- """Draw projected map content onto a base PNG and return a fresh PNG."""
228
- scale = config.map_scale
229
- base = Image.open(io.BytesIO(image_content)).convert("RGBA")
230
-
231
- def to_image(point: Point) -> tuple[float, float]:
232
- return (point.x * scale, point.y * scale)
233
-
234
- draw = ImageDraw.Draw(base, "RGBA")
235
-
236
- # Erase zones are applied to the raster itself (cells blanked), so they are
237
- # not drawn here -- the base image already reflects them.
238
-
239
- # No-go (blue) and no-mop (magenta) zones beneath the path.
240
- for areas, fill, outline in (
241
- (map_data.no_go_areas or [], (0, 120, 255, 70), (0, 80, 200, 255)),
242
- (map_data.no_mopping_areas or [], (255, 0, 200, 70), (200, 0, 160, 255)),
243
- ):
244
- for area in areas:
245
- polygon = [
246
- (area.x0 * scale, area.y0 * scale),
247
- (area.x1 * scale, area.y1 * scale),
248
- (area.x2 * scale, area.y2 * scale),
249
- (area.x3 * scale, area.y3 * scale),
250
- ]
251
- draw.polygon(polygon, fill=fill, outline=outline)
252
-
253
- # Virtual walls (line segments, not polygons) drawn over the zones.
254
- for wall in map_data.walls or []:
255
- draw.line(
256
- [(wall.x0 * scale, wall.y0 * scale), (wall.x1 * scale, wall.y1 * scale)],
257
- fill=(255, 64, 64, 255),
258
- width=max(2, scale),
259
- )
260
-
261
- for path in map_data.path.path if map_data.path else []:
262
- if len(path) >= 2:
263
- draw.line([to_image(point) for point in path], fill=line_color, width=max(1, scale // 2))
264
- if map_data.charger is not None:
265
- dx, dy = to_image(map_data.charger)
266
- draw.ellipse([dx - scale, dy - scale, dx + scale, dy + scale], outline=(40, 200, 40, 255), width=2)
267
- robot_position = map_data.vacuum_position
268
- if robot_position is not None:
269
- cx, cy = to_image(robot_position)
270
- radius = scale
271
- draw.ellipse([cx - radius, cy - radius, cx + radius, cy + radius], fill=position_color)
272
- robot_heading = robot_position.a
273
- if robot_heading is not None:
274
- # Heading was projected into image coordinates alongside the robot
275
- # position, so no calibration state is needed during drawing.
276
- angle = math.radians(robot_heading)
277
- tick = 4 * radius
278
- draw.line(
279
- [cx, cy, cx + math.cos(angle) * tick, cy + math.sin(angle) * tick],
280
- fill=position_color,
281
- width=max(1, scale // 2),
282
- )
324
+ """Draw Q10 content with the shared V1 image generator."""
325
+ if map_data.image is None:
326
+ raise RoborockException("Failed to render Q10 map image")
327
+ generator = _create_image_generator(
328
+ MapParserConfig(map_scale=config.map_scale),
329
+ drawables=_Q10_DRAWABLES,
330
+ )
331
+ generator.draw_map(map_data)
283
332
  buffer = io.BytesIO()
284
- base.save(buffer, format="PNG")
333
+ map_data.image.data.save(buffer, format="PNG")
285
334
  return buffer.getvalue()
@@ -5,10 +5,11 @@ import logging
5
5
  import threading
6
6
  from dataclasses import dataclass, field
7
7
 
8
- from vacuum_map_parser_base.config.color import ColorsPalette, SupportedColor
8
+ from vacuum_map_parser_base.config.color import Color, ColorsPalette, SupportedColor
9
9
  from vacuum_map_parser_base.config.drawable import Drawable
10
10
  from vacuum_map_parser_base.config.image_config import ImageConfig
11
11
  from vacuum_map_parser_base.config.size import Size, Sizes
12
+ from vacuum_map_parser_base.image_generator import ImageGenerator
12
13
  from vacuum_map_parser_base.map_data import MapData
13
14
  from vacuum_map_parser_roborock.image_parser import RoborockImageParser
14
15
  from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser
@@ -150,8 +151,48 @@ class _AdjacencyAwareRoborockImageParser(RoborockImageParser):
150
151
 
151
152
  def _create_map_data_parser(config: MapParserConfig) -> RoborockMapDataParser:
152
153
  """Create a RoborockMapDataParser based on the config entry."""
153
- color_dicts = {}
154
- room_colors = {}
154
+ palette, sizes, image_config = _create_rendering_components(config)
155
+ parser = RoborockMapDataParser(
156
+ palette,
157
+ sizes,
158
+ config.drawables,
159
+ image_config,
160
+ [],
161
+ )
162
+ parser._image_parser = _AdjacencyAwareRoborockImageParser(
163
+ palette,
164
+ image_config,
165
+ recolor_rooms=config.show_rooms,
166
+ )
167
+ return parser
168
+
169
+
170
+ def _create_image_generator(
171
+ config: MapParserConfig,
172
+ *,
173
+ drawables: list[Drawable] | None = None,
174
+ ) -> ImageGenerator:
175
+ """Create the image generator used by V1-compatible package renderers.
176
+
177
+ Other protocols should use this factory for shared drawables instead of
178
+ copying V1 colors, sizes, glyphs, or transparency behavior.
179
+ """
180
+ palette, sizes, image_config = _create_rendering_components(config)
181
+ return ImageGenerator(
182
+ palette,
183
+ sizes,
184
+ config.drawables if drawables is None else drawables,
185
+ image_config,
186
+ [],
187
+ )
188
+
189
+
190
+ def _create_rendering_components(
191
+ config: MapParserConfig,
192
+ ) -> tuple[ColorsPalette, Sizes, ImageConfig]:
193
+ """Build the shared V1 palette, scaled sizes, and image configuration."""
194
+ color_dicts: dict[SupportedColor, Color] = {}
195
+ room_colors: dict[str, Color] = {}
155
196
 
156
197
  if not config.show_background:
157
198
  color_dicts[SupportedColor.MAP_OUTSIDE] = (0, 0, 0, 0)
@@ -162,20 +203,10 @@ def _create_map_data_parser(config: MapParserConfig) -> RoborockMapDataParser:
162
203
  color_dicts[SupportedColor.MAP_WALL_V2] = (0, 0, 0, 0)
163
204
 
164
205
  if not config.show_rooms:
165
- room_colors = {str(x): (0, 0, 0, 0) for x in range(1, 32)}
206
+ room_colors = {str(room_id): (0, 0, 0, 0) for room_id in range(1, 32)}
166
207
 
167
- palette = ColorsPalette(color_dicts, room_colors)
168
- image_config = ImageConfig(scale=config.map_scale)
169
- parser = RoborockMapDataParser(
170
- palette,
208
+ return (
209
+ ColorsPalette(color_dicts, room_colors),
171
210
  Sizes({k: v * config.map_scale for k, v in Sizes.SIZES.items() if k != Size.MOP_PATH_WIDTH}),
172
- config.drawables,
173
- image_config,
174
- [],
175
- )
176
- parser._image_parser = _AdjacencyAwareRoborockImageParser(
177
- palette,
178
- image_config,
179
- recolor_rooms=config.show_rooms,
211
+ ImageConfig(scale=config.map_scale),
180
212
  )
181
- return parser
File without changes