python-roborock 5.16.0__tar.gz → 5.18.0__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 (106) hide show
  1. {python_roborock-5.16.0 → python_roborock-5.18.0}/PKG-INFO +1 -1
  2. {python_roborock-5.16.0 → python_roborock-5.18.0}/pyproject.toml +1 -1
  3. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/b01/q7/__init__.py +4 -0
  4. python_roborock-5.18.0/roborock/map/b01_grid_layers.py +253 -0
  5. python_roborock-5.18.0/roborock/map/b01_q10_overlays.py +99 -0
  6. {python_roborock-5.16.0 → python_roborock-5.18.0}/.gitignore +0 -0
  7. {python_roborock-5.16.0 → python_roborock-5.18.0}/LICENSE +0 -0
  8. {python_roborock-5.16.0 → python_roborock-5.18.0}/README.md +0 -0
  9. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/__init__.py +0 -0
  10. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/broadcast_protocol.py +0 -0
  11. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/callbacks.py +0 -0
  12. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/cli.py +0 -0
  13. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/const.py +0 -0
  14. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/data/__init__.py +0 -0
  15. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/data/b01_q10/__init__.py +0 -0
  16. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/data/b01_q10/b01_q10_code_mappings.py +0 -0
  17. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/data/b01_q10/b01_q10_containers.py +0 -0
  18. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/data/b01_q7/__init__.py +0 -0
  19. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/data/b01_q7/b01_q7_code_mappings.py +0 -0
  20. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/data/b01_q7/b01_q7_containers.py +0 -0
  21. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/data/code_mappings.py +0 -0
  22. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/data/containers.py +0 -0
  23. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/data/dyad/__init__.py +0 -0
  24. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/data/dyad/dyad_code_mappings.py +0 -0
  25. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/data/dyad/dyad_containers.py +0 -0
  26. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/data/v1/__init__.py +0 -0
  27. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/data/v1/v1_clean_modes.py +0 -0
  28. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/data/v1/v1_code_mappings.py +0 -0
  29. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/data/v1/v1_containers.py +0 -0
  30. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/data/zeo/__init__.py +0 -0
  31. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/data/zeo/zeo_code_mappings.py +0 -0
  32. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/data/zeo/zeo_containers.py +0 -0
  33. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/device_features.py +0 -0
  34. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/README.md +0 -0
  35. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/__init__.py +0 -0
  36. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/cache.py +0 -0
  37. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/device.py +0 -0
  38. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/device_manager.py +0 -0
  39. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/file_cache.py +0 -0
  40. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/rpc/__init__.py +0 -0
  41. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/rpc/a01_channel.py +0 -0
  42. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/rpc/b01_q10_channel.py +0 -0
  43. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/rpc/b01_q7_channel.py +0 -0
  44. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/rpc/v1_channel.py +0 -0
  45. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/__init__.py +0 -0
  46. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/a01/__init__.py +0 -0
  47. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/b01/__init__.py +0 -0
  48. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/b01/q10/__init__.py +0 -0
  49. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/b01/q10/command.py +0 -0
  50. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/b01/q10/remote.py +0 -0
  51. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/b01/q10/status.py +0 -0
  52. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/b01/q10/vacuum.py +0 -0
  53. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/b01/q7/clean_summary.py +0 -0
  54. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/b01/q7/map.py +0 -0
  55. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/b01/q7/map_content.py +0 -0
  56. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/common.py +0 -0
  57. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/traits_mixin.py +0 -0
  58. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/v1/__init__.py +0 -0
  59. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/v1/child_lock.py +0 -0
  60. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/v1/clean_summary.py +0 -0
  61. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/v1/command.py +0 -0
  62. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/v1/common.py +0 -0
  63. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/v1/consumeable.py +0 -0
  64. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/v1/device_features.py +0 -0
  65. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/v1/do_not_disturb.py +0 -0
  66. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/v1/dust_collection_mode.py +0 -0
  67. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/v1/flow_led_status.py +0 -0
  68. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/v1/home.py +0 -0
  69. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/v1/led_status.py +0 -0
  70. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/v1/map_content.py +0 -0
  71. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/v1/maps.py +0 -0
  72. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/v1/network_info.py +0 -0
  73. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/v1/rooms.py +0 -0
  74. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/v1/routines.py +0 -0
  75. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/v1/smart_wash_params.py +0 -0
  76. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/v1/status.py +0 -0
  77. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/v1/valley_electricity_timer.py +0 -0
  78. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/v1/volume.py +0 -0
  79. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/traits/v1/wash_towel_mode.py +0 -0
  80. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/transport/__init__.py +0 -0
  81. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/transport/channel.py +0 -0
  82. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/transport/local_channel.py +0 -0
  83. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/devices/transport/mqtt_channel.py +0 -0
  84. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/diagnostics.py +0 -0
  85. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/exceptions.py +0 -0
  86. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/map/__init__.py +0 -0
  87. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/map/b01_map_parser.py +0 -0
  88. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/map/map_parser.py +0 -0
  89. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/map/proto/__init__.py +0 -0
  90. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/map/proto/b01_scmap.proto +0 -0
  91. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/map/proto/b01_scmap_pb2.py +0 -0
  92. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/mqtt/__init__.py +0 -0
  93. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/mqtt/health_manager.py +0 -0
  94. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/mqtt/roborock_session.py +0 -0
  95. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/mqtt/session.py +0 -0
  96. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/protocol.py +0 -0
  97. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/protocols/__init__.py +0 -0
  98. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/protocols/a01_protocol.py +0 -0
  99. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/protocols/b01_q10_protocol.py +0 -0
  100. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/protocols/b01_q7_protocol.py +0 -0
  101. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/protocols/v1_protocol.py +0 -0
  102. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/py.typed +0 -0
  103. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/roborock_message.py +0 -0
  104. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/roborock_typing.py +0 -0
  105. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/util.py +0 -0
  106. {python_roborock-5.16.0 → python_roborock-5.18.0}/roborock/web_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-roborock
3
- Version: 5.16.0
3
+ Version: 5.18.0
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 = "5.16.0"
3
+ version = "5.18.0"
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"
@@ -112,6 +112,10 @@ class Q7PropertiesApi(Trait):
112
112
  """Set the robot voice volume (0-100)."""
113
113
  await self.set_prop(RoborockB01Props.VOLUME, volume)
114
114
 
115
+ async def set_child_lock(self, enabled: bool) -> None:
116
+ """Enable or disable the child lock."""
117
+ await self.set_prop(RoborockB01Props.CHILD_LOCK, int(enabled))
118
+
115
119
  async def start_clean(self) -> None:
116
120
  """Start cleaning."""
117
121
  await self.send(
@@ -0,0 +1,253 @@
1
+ """Device-agnostic decomposition of a B01 occupancy grid into map layers.
2
+
3
+ Both the Q10 (custom binary) and Q7 (SCMap protobuf) deliver their map as a
4
+ single-byte-per-cell occupancy grid where the cell *value* encodes a semantic
5
+ class (background / wall / per-room floor / ...). This module turns such a grid
6
+ into separable **layers** a frontend can stack, without knowing the device's
7
+ specific value encoding -- the caller supplies a ``classifier`` mapping a cell
8
+ value to a class name, plus the room metadata.
9
+
10
+ Coordinates here are **grid-pixel** space (origin top-left of the raw grid, before
11
+ any rendering flip/scale). Vector overlays in world/robot coordinates (path,
12
+ zones, ...) are placed into this same space by the device's calibration.
13
+ """
14
+
15
+ import io
16
+ from collections.abc import Callable, Iterable
17
+ from dataclasses import dataclass, field
18
+ from math import ceil
19
+
20
+ from PIL import Image
21
+
22
+ # Canonical layer class names. Devices map their raw cell values onto these.
23
+ LAYER_BACKGROUND = "background"
24
+ LAYER_WALL = "wall"
25
+ LAYER_FLOOR = "floor"
26
+ LAYER_UNKNOWN = "unknown"
27
+
28
+ _PNG = "PNG"
29
+
30
+
31
+ @dataclass
32
+ class RoomLayer:
33
+ """A single room (segment) and where its pixels sit in the grid."""
34
+
35
+ id: int
36
+ name: str
37
+ pixel_value: int
38
+ pixel_count: int
39
+ bbox: tuple[int, int, int, int]
40
+ """Inclusive ``(min_x, min_y, max_x, max_y)`` bounding box in grid pixels."""
41
+
42
+ @property
43
+ def center(self) -> tuple[float, float]:
44
+ """Center of the bounding box in grid-pixel space (for label placement)."""
45
+ min_x, min_y, max_x, max_y = self.bbox
46
+ return ((min_x + max_x) / 2, (min_y + max_y) / 2)
47
+
48
+
49
+ @dataclass
50
+ class GridLayers:
51
+ """Separable layers decomposed from a single occupancy grid.
52
+
53
+ Holds a reference to the raw ``grid`` plus the classifier, and renders any
54
+ layer to a transparent RGBA PNG on demand (so we don't eagerly build a mask
55
+ per room). ``class_counts`` reports how many cells fall in each class.
56
+ """
57
+
58
+ width: int
59
+ height: int
60
+ grid: bytes
61
+ rooms: list[RoomLayer]
62
+ classifier: Callable[[int], str]
63
+ class_counts: dict[str, int] = field(default_factory=dict)
64
+ flip: bool = True
65
+ """Whether display rendering flips the grid top-to-bottom. Devices whose grid
66
+ is stored bottom-up (V1/Q7 convention) flip; the Q10 grid is stored top-down,
67
+ so it does not. Used as the default for the ``render_*`` methods so every
68
+ layer matches the device's composited map orientation."""
69
+
70
+ def cell_class(self, value: int) -> str:
71
+ """Classify a single raw cell value into a canonical layer name."""
72
+ return self.classifier(value)
73
+
74
+ def render_mask(
75
+ self,
76
+ predicate: Callable[[int], bool],
77
+ color: tuple[int, int, int, int],
78
+ *,
79
+ scale: int = 1,
80
+ flip: bool | None = None,
81
+ ) -> bytes:
82
+ """Render cells matching ``predicate`` as ``color`` over transparency.
83
+
84
+ ``flip`` applies the same top-to-bottom flip the composited map uses so
85
+ layers line up pixel-for-pixel (defaults to the device's :attr:`flip`);
86
+ ``scale`` nearest-neighbour upsamples.
87
+ """
88
+ if flip is None:
89
+ flip = self.flip
90
+ transparent = (0, 0, 0, 0)
91
+ px = bytearray()
92
+ for value in self.grid:
93
+ px.extend(color if predicate(value) else transparent)
94
+ img = Image.frombytes("RGBA", (self.width, self.height), bytes(px))
95
+ if flip:
96
+ img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
97
+ if scale > 1:
98
+ img = img.resize((self.width * scale, self.height * scale), Image.Resampling.NEAREST)
99
+ buf = io.BytesIO()
100
+ img.save(buf, format=_PNG)
101
+ return buf.getvalue()
102
+
103
+ def render_class(
104
+ self, layer: str, color: tuple[int, int, int, int], *, scale: int = 1, flip: bool | None = None
105
+ ) -> bytes:
106
+ """Render a whole class layer (e.g. ``"wall"``) to an RGBA PNG."""
107
+ return self.render_mask(lambda v: self.classifier(v) == layer, color, scale=scale, flip=flip)
108
+
109
+ def render_room(
110
+ self, room_id: int, color: tuple[int, int, int, int], *, scale: int = 1, flip: bool | None = None
111
+ ) -> bytes:
112
+ """Render a single room's pixels to an RGBA PNG."""
113
+ room = next((r for r in self.rooms if r.id == room_id), None)
114
+ if room is None:
115
+ raise KeyError(f"No room with id {room_id}")
116
+ target = room.pixel_value
117
+ return self.render_mask(lambda v: v == target, color, scale=scale, flip=flip)
118
+
119
+
120
+ @dataclass
121
+ class GridCalibration:
122
+ """Affine transform between device world coordinates and grid pixels.
123
+
124
+ ``resolution`` is world-units per pixel. The Y axis is flipped between world
125
+ and grid space via ``y_sign`` (devices typically have world-Y increasing
126
+ upward while the grid's Y increases downward).
127
+ """
128
+
129
+ resolution: float
130
+ origin_x: float
131
+ origin_y: float
132
+ y_sign: int = 1
133
+
134
+ def world_to_pixel(self, x: float, y: float) -> tuple[float, float]:
135
+ """Map a world ``(x, y)`` to grid-pixel ``(px, py)``."""
136
+ return (x / self.resolution + self.origin_x, self.origin_y - self.y_sign * y / self.resolution)
137
+
138
+ def pixel_to_world(self, px: float, py: float) -> tuple[float, float]:
139
+ """Map a grid-pixel ``(px, py)`` back to world ``(x, y)``."""
140
+ return ((px - self.origin_x) * self.resolution, self.y_sign * (self.origin_y - py) * self.resolution)
141
+
142
+
143
+ def solve_calibration(
144
+ layers: GridLayers,
145
+ points: list[tuple[float, float]],
146
+ *,
147
+ resolutions: Iterable[float],
148
+ y_signs: Iterable[int] = (1, -1),
149
+ ) -> GridCalibration | None:
150
+ """Fit a :class:`GridCalibration` by overlaying ``points`` onto the floor.
151
+
152
+ A cleaning path must lie on floor (not walls/background). For each candidate
153
+ resolution and Y orientation we slide the path's pixel bounding box across
154
+ the map and keep the placement that maximises on-floor points while
155
+ penalising points landing on walls/background. Returns ``None`` if no
156
+ placement lands a clear majority of points on floor.
157
+
158
+ The search is bounded: the path bbox size fixes how far it can slide, so the
159
+ offset range is small. Needs a reasonably dense, shape-rich path (a long
160
+ clean) to be well-constrained.
161
+ """
162
+ if not points:
163
+ return None
164
+ w, h = layers.width, layers.height
165
+ classify = layers.classifier
166
+ # 1 = floor, 2 = wall/background (blocked), 0 = other. Index by cell.
167
+ klass = bytes(
168
+ 1 if (c := classify(v)) == LAYER_FLOOR else 2 if c in (LAYER_WALL, LAYER_BACKGROUND) else 0 for v in layers.grid
169
+ )
170
+
171
+ best: tuple[float, GridCalibration] | None = None
172
+ for resolution in resolutions:
173
+ if resolution <= 0:
174
+ continue
175
+ for y_sign in y_signs:
176
+ sx = [x / resolution for x, _ in points]
177
+ sy = [y_sign * y / resolution for _, y in points]
178
+ min_sx, max_sx, min_sy, max_sy = min(sx), max(sx), min(sy), max(sy)
179
+ if (max_sx - min_sx) >= w or (max_sy - min_sy) >= h:
180
+ continue # path wider/taller than the map at this resolution
181
+ pts = list(zip(sx, sy))
182
+ # Slide so every point stays in-bounds: px = px_f + ox in [0, w), py = oy - py_f in [0, h).
183
+ for ox in range(ceil(-min_sx), ceil(w - max_sx)):
184
+ for oy in range(ceil(max_sy), ceil(h + min_sy)):
185
+ on_floor = 0
186
+ blocked = 0
187
+ for px_f, py_f in pts:
188
+ cell = int(oy - py_f) * w + int(px_f + ox)
189
+ k = klass[cell]
190
+ if k == 1:
191
+ on_floor += 1
192
+ elif k == 2:
193
+ blocked += 1
194
+ score = on_floor - 1.5 * blocked
195
+ if best is None or score > best[0]:
196
+ best = (score, GridCalibration(float(resolution), float(ox), float(oy), y_sign))
197
+
198
+ if best is None or best[0] < len(points) * 0.5:
199
+ return None
200
+ return best[1]
201
+
202
+
203
+ def decompose_grid(
204
+ width: int,
205
+ height: int,
206
+ grid: bytes,
207
+ rooms: Iterable[tuple[int, str, int, int]],
208
+ classifier: Callable[[int], str],
209
+ *,
210
+ flip: bool = True,
211
+ ) -> GridLayers:
212
+ """Build :class:`GridLayers` from a grid + room records + a classifier.
213
+
214
+ ``rooms`` items are ``(id, name, pixel_value, pixel_count)`` tuples. Per-room
215
+ bounding boxes are computed in one pass over the grid. ``flip`` records the
216
+ device's display orientation (see :attr:`GridLayers.flip`).
217
+ """
218
+ room_meta = list(rooms)
219
+ bboxes: dict[int, list[int]] = {pv: [width, height, -1, -1] for (_, _, pv, _) in room_meta}
220
+ counts: dict[str, int] = {}
221
+ for index, value in enumerate(grid):
222
+ cls = classifier(value)
223
+ counts[cls] = counts.get(cls, 0) + 1
224
+ box = bboxes.get(value)
225
+ if box is not None:
226
+ x = index % width
227
+ y = index // width
228
+ if x < box[0]:
229
+ box[0] = x
230
+ if y < box[1]:
231
+ box[1] = y
232
+ if x > box[2]:
233
+ box[2] = x
234
+ if y > box[3]:
235
+ box[3] = y
236
+
237
+ room_layers: list[RoomLayer] = []
238
+ for room_id, name, pixel_value, pixel_count in room_meta:
239
+ box = bboxes[pixel_value]
240
+ bbox = (box[0], box[1], box[2], box[3]) if box[2] >= 0 else (0, 0, 0, 0)
241
+ room_layers.append(
242
+ RoomLayer(id=room_id, name=name, pixel_value=pixel_value, pixel_count=pixel_count, bbox=bbox)
243
+ )
244
+
245
+ return GridLayers(
246
+ width=width,
247
+ height=height,
248
+ grid=grid,
249
+ rooms=room_layers,
250
+ classifier=classifier,
251
+ class_counts=counts,
252
+ flip=flip,
253
+ )
@@ -0,0 +1,99 @@
1
+ """Decoders for Q10 (B01/ss07) map vector overlays.
2
+
3
+ No-go zones, no-mop zones, virtual walls and zoned-clean areas are not part of
4
+ the map raster; the device reports them as base64-encoded blobs in separate data
5
+ points (``dpRestrictedZoneUp`` 55, ``dpVirtualWallUp`` 57, ``dpZonedUp`` 59).
6
+
7
+ The blob format was reverse-engineered from a live ss07 (confirmed against 7
8
+ real no-go zones):
9
+
10
+ [version: u8][count: u8] then ``count`` fixed-size records, each:
11
+ [type: u8][vertex_count: u8] then vertex_count (x, y) int16-BE pairs,
12
+ zero-padded to the record size.
13
+
14
+ Coordinates are in the device's world units (the same space as the cleaning
15
+ path), so a :class:`~roborock.map.b01_grid_layers.GridCalibration` maps them to
16
+ map pixels. ``type`` distinguishes the restriction kind (2 = no-mop, 3 = door
17
+ threshold, anything else -- incl. 0 -- a no-go zone); it is preserved verbatim
18
+ so callers can route polygons to the right ``MapData`` layer.
19
+ """
20
+
21
+ import base64
22
+ from dataclasses import dataclass, field
23
+
24
+ _DEFAULT_RECORD_SIZE = 38 # 2-byte record header + up to 9 (x, y) int16 pairs
25
+
26
+
27
+ @dataclass
28
+ class Q10Zone:
29
+ """A polygon overlay (no-go / no-mop / virtual wall) in world coordinates."""
30
+
31
+ type: int
32
+ vertices: list[tuple[int, int]] = field(default_factory=list)
33
+
34
+
35
+ def _as_bytes(data: bytes | str | None) -> bytes:
36
+ if data is None:
37
+ return b""
38
+ if isinstance(data, bytes):
39
+ return data
40
+ try:
41
+ return base64.b64decode(data + "=" * (-len(data) % 4))
42
+ except (ValueError, base64.binascii.Error): # type: ignore[attr-defined]
43
+ return b""
44
+
45
+
46
+ def parse_zone_blob(data: bytes | str | None) -> list[Q10Zone]:
47
+ """Decode a Q10 zone/wall overlay blob into a list of :class:`Q10Zone`.
48
+
49
+ Accepts the raw bytes or the base64 string straight from the data point.
50
+ Returns ``[]`` for empty/absent/unparsable blobs (the device sends a single
51
+ ``0x00`` byte when there are none).
52
+ """
53
+ raw = _as_bytes(data)
54
+ if len(raw) < 2:
55
+ return []
56
+ count = raw[1]
57
+ if count <= 0:
58
+ return []
59
+
60
+ body = raw[2:]
61
+ record_size = len(body) // count if count and len(body) % count == 0 else _DEFAULT_RECORD_SIZE
62
+ if record_size < 2:
63
+ return []
64
+
65
+ zones: list[Q10Zone] = []
66
+ for index in range(count):
67
+ record = body[index * record_size : (index + 1) * record_size]
68
+ if len(record) < 2:
69
+ break
70
+ zone_type = record[0]
71
+ vertex_count = record[1]
72
+ needed = 2 + vertex_count * 4
73
+ if needed > len(record):
74
+ continue # malformed record; skip rather than misread padding
75
+ vertices = [
76
+ (
77
+ int.from_bytes(record[2 + j * 4 : 4 + j * 4], "big", signed=True),
78
+ int.from_bytes(record[4 + j * 4 : 6 + j * 4], "big", signed=True),
79
+ )
80
+ for j in range(vertex_count)
81
+ ]
82
+ zones.append(Q10Zone(type=zone_type, vertices=vertices))
83
+ return zones
84
+
85
+
86
+ # Observed ``type`` values, confirmed against an ss07 Q10 (firmware 03.11.24) and
87
+ # cross-checked with the ioBroker roborock adapter: 2 = no-mop, 3 = door
88
+ # threshold, 1 = virtual wall. Any other value (including 0) is a no-go zone.
89
+ # In practice virtual walls arrive on a separate DP (VIRTUAL_WALL_UP 57), so this
90
+ # restricted-zone DP normally only carries 0 / 2 / 3. The raw value is also kept
91
+ # on ``Q10Zone.type`` for callers that recognise it.
92
+ #
93
+ # Corrected from an earlier reading that treated type 3 as no-mop -- 3 is the
94
+ # door-threshold rectangle; the no-mop area reads back as type 2. Reported and
95
+ # verified by @andrewlyeats (ss07 read-backs + the ioBroker Q10 parser).
96
+ ZONE_TYPE_NO_GO = 0
97
+ ZONE_TYPE_VIRTUAL_WALL = 1
98
+ ZONE_TYPE_NO_MOP = 2
99
+ ZONE_TYPE_THRESHOLD = 3