Skip to content

Commit 4f108b8

Browse files
fix: decode Q10 virtual walls (DP 57) with their own frame
Virtual walls (dpVirtualWallUp 57) use a different on-wire frame from the restricted-zone DPs: a bare [count] byte (no version, no per-record type/pad) then 8-byte (y, x) int16-BE records. Feeding such a blob to parse_zone_blob mis-frames it (leading 0x01 read as a version, the next coordinate byte as a record count), so virtual_walls silently came back empty and the wall overlay never rendered. Add parse_virtual_wall_blob (axes un-swapped to (x, y) so walls share the restricted-zone coordinate order), point load_overlays at it for DP 57, and correct the overlay module's docs that wrongly claimed parse_zone_blob handled DP 57. Tested against a real ss07 read-back from the PR #850 thread.
1 parent 48d70c9 commit 4f108b8

3 files changed

Lines changed: 119 additions & 8 deletions

File tree

roborock/devices/traits/b01/q10/map.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,13 @@
4343
parse_map_packet,
4444
parse_trace_packet,
4545
)
46-
from roborock.map.b01_q10_overlays import ZONE_TYPE_NO_GO, ZONE_TYPE_NO_MOP, Q10Zone, parse_zone_blob
46+
from roborock.map.b01_q10_overlays import (
47+
ZONE_TYPE_NO_GO,
48+
ZONE_TYPE_NO_MOP,
49+
Q10Zone,
50+
parse_virtual_wall_blob,
51+
parse_zone_blob,
52+
)
4753
from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol
4854

4955
_LOGGER = logging.getLogger(__name__)
@@ -264,7 +270,7 @@ def load_overlays(
264270
if restricted_zone_up is not None:
265271
self.zones = parse_zone_blob(restricted_zone_up)
266272
if virtual_wall_up is not None:
267-
self.virtual_walls = parse_zone_blob(virtual_wall_up)
273+
self.virtual_walls = parse_virtual_wall_blob(virtual_wall_up)
268274
if self.calibration is not None:
269275
self._place_zones_on_map_data(self.calibration)
270276

roborock/map/b01_q10_overlays.py

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,20 @@
44
the map raster; the device reports them as base64-encoded blobs in separate data
55
points (``dpRestrictedZoneUp`` 55, ``dpVirtualWallUp`` 57, ``dpZonedUp`` 59).
66
7-
The blob format was reverse-engineered from a live ss07 (confirmed against 7
8-
real no-go zones):
7+
The restricted-zone / zoned blob format (DP 55 and DP 59) was reverse-engineered
8+
from a live ss07 (confirmed against 7 real no-go zones):
99
1010
[version: u8][count: u8] then ``count`` fixed-size records, each:
1111
[type: u8][vertex_count: u8] then vertex_count (x, y) int16-BE pairs,
1212
zero-padded to the record size.
1313
14+
Use :func:`parse_zone_blob` for those. Virtual walls (DP 57) use a *different*
15+
frame -- a bare ``[count]`` and 8-byte ``(y, x)`` records, no version/type/pad --
16+
so they have their own :func:`parse_virtual_wall_blob`; feeding DP 57 to
17+
:func:`parse_zone_blob` mis-frames it (the leading byte is read as a version and
18+
the next, a coordinate, as a record count). Provenance and the byte-level
19+
breakdown are in PR #850's review thread.
20+
1421
Coordinates are in the device's world units (the same space as the cleaning
1522
path), so a :class:`~roborock.map.b01_grid_layers.GridCalibration` maps them to
1623
map pixels. ``type`` distinguishes the restriction kind (2 = no-mop, 3 = door
@@ -83,12 +90,61 @@ def parse_zone_blob(data: bytes | str | None) -> list[Q10Zone]:
8390
return zones
8491

8592

93+
_WALL_RECORD_SIZE = 8 # two int16-BE endpoints, stored (y, x) on the wire
94+
95+
96+
def parse_virtual_wall_blob(data: bytes | str | None) -> list[Q10Zone]:
97+
"""Decode a Q10 virtual-wall overlay blob (``dpVirtualWallUp`` 57).
98+
99+
Virtual walls use a *different* frame from the restricted-zone DPs handled by
100+
:func:`parse_zone_blob`: a single ``[count: u8]`` byte (no version, no
101+
per-record type/pad) followed by ``count`` 8-byte records, each two
102+
``(y, x)`` int16-BE endpoints -- note the swapped axis order.
103+
104+
Each wall is returned as a :class:`Q10Zone` of type
105+
:data:`ZONE_TYPE_VIRTUAL_WALL` with its two endpoints as ``(x, y)`` vertices
106+
(axes un-swapped to match the restricted-zone order), so callers can place
107+
them onto the map through the same
108+
:class:`~roborock.map.b01_grid_layers.GridCalibration`.
109+
110+
Accepts raw bytes or the base64 string straight from the data point. Returns
111+
``[]`` for empty/absent/unparsable blobs (the device sends a single ``0x00``
112+
byte -- base64 ``AA==`` -- when there are none).
113+
"""
114+
raw = _as_bytes(data)
115+
if len(raw) < 1:
116+
return []
117+
count = raw[0]
118+
if count <= 0:
119+
return []
120+
121+
body = raw[1:]
122+
walls: list[Q10Zone] = []
123+
for index in range(count):
124+
record = body[index * _WALL_RECORD_SIZE : (index + 1) * _WALL_RECORD_SIZE]
125+
if len(record) < _WALL_RECORD_SIZE:
126+
break # truncated trailing record; stop rather than misread
127+
vertices = [
128+
(
129+
# records are (y, x) on the wire; swap to (x, y) for the caller
130+
int.from_bytes(record[2 + p * 4 : 4 + p * 4], "big", signed=True),
131+
int.from_bytes(record[p * 4 : 2 + p * 4], "big", signed=True),
132+
)
133+
for p in range(2)
134+
]
135+
walls.append(Q10Zone(type=ZONE_TYPE_VIRTUAL_WALL, vertices=vertices))
136+
return walls
137+
138+
86139
# Observed ``type`` values, confirmed against an ss07 Q10 (firmware 03.11.24) and
87140
# 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.
141+
# threshold. Any other value (including 0) is a no-go zone. The raw value is also
142+
# kept on ``Q10Zone.type`` for callers that recognise it.
143+
#
144+
# Virtual walls arrive on a separate DP (VIRTUAL_WALL_UP 57) with their own frame
145+
# (see :func:`parse_virtual_wall_blob`), so this restricted-zone DP only carries
146+
# 0 / 2 / 3 -- never a 1. ``ZONE_TYPE_VIRTUAL_WALL`` is kept here only to tag the
147+
# walls that :func:`parse_virtual_wall_blob` produces.
92148
#
93149
# Corrected from an earlier reading that treated type 3 as no-mop -- 3 is the
94150
# door-threshold rectangle; the no-mop area reads back as type 2. Reported and

tests/map/test_b01_q10_overlays.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
ZONE_TYPE_NO_MOP,
88
ZONE_TYPE_THRESHOLD,
99
ZONE_TYPE_VIRTUAL_WALL,
10+
parse_virtual_wall_blob,
1011
parse_zone_blob,
1112
)
1213

@@ -71,3 +72,51 @@ def test_parse_zone_blob_real_record_size_inferred() -> None:
7172
rect = _rect(ZONE_TYPE_NO_GO, [(100, 200), (300, 200), (300, 50), (100, 50)])
7273
zones = parse_zone_blob(_blob(1, [rect], record_size=38))
7374
assert len(zones) == 1 and zones[0].vertices[0] == (100, 200)
75+
76+
77+
def test_parse_virtual_wall_blob_real_capture() -> None:
78+
"""Real DP-57 read-back from an ss07 (one wall drawn in the app).
79+
80+
Frame is ``[count]`` + 8-byte ``(y, x)`` records -- no version byte. The
81+
decoder swaps axes to ``(x, y)`` so walls share the restricted-zone order.
82+
Provenance: PR #850 review thread.
83+
"""
84+
walls = parse_virtual_wall_blob("Aflu+cX87PoO")
85+
assert len(walls) == 1
86+
assert walls[0].type == ZONE_TYPE_VIRTUAL_WALL
87+
assert walls[0].vertices == [(-1595, -1682), (-1522, -788)]
88+
89+
90+
def test_parse_virtual_wall_blob_empty_variants() -> None:
91+
assert parse_virtual_wall_blob(None) == []
92+
assert parse_virtual_wall_blob(b"\x00") == [] # device's "no walls" sentinel
93+
assert parse_virtual_wall_blob("AA==") == [] # base64 of 0x00
94+
95+
96+
def test_parse_virtual_wall_blob_multiple_walls() -> None:
97+
"""Two walls back-to-back; each is a separate 8-byte (y, x) record."""
98+
blob = bytes([2]) + bytes(
99+
[
100+
0x00, 0x0A, 0x00, 0x14, 0x00, 0x1E, 0x00, 0x28, # (y,x)=(10,20)->(30,40)
101+
0xFF, 0xFB, 0x00, 0x05, 0xFF, 0xF6, 0x00, 0x0A, # (y,x)=(-5,5)->(-10,10)
102+
]
103+
)
104+
walls = parse_virtual_wall_blob(blob)
105+
assert [w.vertices for w in walls] == [[(20, 10), (40, 30)], [(5, -5), (10, -10)]]
106+
107+
108+
def test_parse_virtual_wall_blob_truncated_record_dropped() -> None:
109+
"""A trailing record shorter than 8 bytes is dropped, not misread."""
110+
blob = bytes([2]) + bytes([0x00, 0x0A, 0x00, 0x14, 0x00, 0x1E, 0x00, 0x28]) + b"\x00\x00"
111+
walls = parse_virtual_wall_blob(blob)
112+
assert [w.vertices for w in walls] == [[(20, 10), (40, 30)]]
113+
114+
115+
def test_zone_parser_misframes_virtual_wall_blob() -> None:
116+
"""The restricted-zone parser must NOT be used on DP 57 -- it mis-frames it.
117+
118+
Regression guard for the original bug: a DP-57 blob fed to parse_zone_blob
119+
reads the leading 0x01 as a version and the next coordinate byte as a record
120+
count, yielding garbage (here: nothing), so DP 57 needs its own decoder.
121+
"""
122+
assert parse_zone_blob("Aflu+cX87PoO") != parse_virtual_wall_blob("Aflu+cX87PoO")

0 commit comments

Comments
 (0)