Skip to content

feat: B01 grid-layer decomposition + Q10 vector overlay decoding#850

Merged
allenporter merged 2 commits into
Python-roborock:mainfrom
tubededentifrice:b01-grid-layers-overlays
Jun 21, 2026
Merged

feat: B01 grid-layer decomposition + Q10 vector overlay decoding#850
allenporter merged 2 commits into
Python-roborock:mainfrom
tubededentifrice:b01-grid-layers-overlays

Conversation

@tubededentifrice

@tubededentifrice tubededentifrice commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Splits out the standalone, low-level map-parsing primitives from #848 so they can be reviewed in parallel, without waiting on #846 and #847. Both modules are pure transforms with no dependencies on the rest of the package, and their tests build all inputs inline (no binary fixtures).

This is branched directly off main — independent of #846/#847/#848. Once it merges, #848 rebases on top and shrinks to the parts that genuinely depend on the Q10 map parser (calibration wiring, parser integration, CLI, Q7 reuse).

Changes

  • roborock/map/b01_grid_layers.py — device-agnostic occupancy-grid decomposition: split a grid into background / wall / per-room floor layers (via a caller-supplied classify callable), render any class or room as a transparent RGBA PNG, and fit a GridCalibration (resolution / origin / y-sign) from a known path with solve_calibration().
  • roborock/map/b01_q10_overlays.py — decode Q10 restricted-zone blobs into no-go, no-mop, and virtual-wall overlays.

Testing

  • uv run pytest tests/map/test_b01_grid_layers.py tests/map/test_b01_q10_overlays.py — 12 passed
  • uv run pre-commit run --files ... — ruff format, ruff, mypy all pass

Two standalone, device-agnostic map-parsing primitives with no
dependencies on the rest of the package:

- b01_grid_layers: decompose an occupancy grid into background/wall/
  per-room floor layers, render each as a transparent PNG, and fit a
  GridCalibration (resolution/origin) from a known path via
  solve_calibration().
- b01_q10_overlays: decode Q10 restricted-zone blobs into no-go,
  no-mop, and virtual-wall overlays.

Both are pure transforms (grid/bytes in, structured data out) and are
fully covered by self-contained tests that build their inputs inline,
so no binary fixtures are needed.
@andrewlyeats

Copy link
Copy Markdown
Contributor

Thanks for decomposing the B01 grid and overlays.

One data point in case it helps, on the no-mop type. On our Q10 (roborock.vacuum.ss07, firmware 03.11.24) we consistently read back, from the restricted-zone DP (RESTRICTED_ZONE_UP):

  • 0x00no-go (matches yours)
  • 0x02no-mop
  • 0x03door-threshold (the thin rectangle you draw across a doorway in the app)

i.e. drawing a "no-mop" area in the app and reading the DP back gives type byte 0x02, while the door-threshold rectangle gives 0x03.

(For us, virtual walls read back through a separate DP, VIRTUAL_WALL_UP (57); in this restricted-zone DP we've only observed these three types. ioBroker splits walls out the same way.)

This lines up with ioBroker's Q10 parser, which decodes the restricted-zone payload as areaType 2 → mop, 3 → threshold, otherwise forbid (no-go), and handles virtual walls separately as type 1: parseQ10RestrictedZoneDpPayload.

Since this PR currently treats 3 as no-mop (ZONE_TYPE_NO_MOP = 3), I wanted to flag the possible overlap with the threshold type — could the 3 you observed have come from a door-threshold zone, or might this be a firmware/region variant? Happy to share our raw read-backs (zone drawn → DP echoed) or to test a specific case if that would help pin it down.

The restricted-zone DP (RESTRICTED_ZONE_UP) encodes no-mop as type 2 and
the door-threshold rectangle as type 3; type 1 is a virtual wall (which in
practice arrives on its own DP) and any other value, including 0, is a
no-go zone. The previous reading mislabelled type 3 as no-mop.

Add ZONE_TYPE_VIRTUAL_WALL (1) and ZONE_TYPE_THRESHOLD (3); set
ZONE_TYPE_NO_MOP = 2. Confirmed on ss07 (firmware 03.11.24) read-backs and
cross-checked against the ioBroker roborock Q10 parser. Reported and
verified by @andrewlyeats.
@tubededentifrice

Copy link
Copy Markdown
Contributor Author

Thanks @andrewlyeats — you're right, and I've corrected it in aa321fd (credited in the commit and code comments).

I verified your read-back against the ioBroker roborock adapter's parseQ10RestrictedZoneDpPayload, which decodes the restricted-zone DP exactly as you describe:

areaType === 2 ? "mop" : areaType === 3 ? "threshold" : "forbid"

with virtual walls handled separately as areaType: 1. That matches your ss07 (fw 03.11.24) read-backs (0x02 → no-mop, 0x03 → door-threshold), so the 3 I originally observed was indeed a door-threshold rectangle, not a no-mop area.

Fix:

  • ZONE_TYPE_NO_MOP = 2 (was 3)
  • added ZONE_TYPE_THRESHOLD = 3 and ZONE_TYPE_VIRTUAL_WALL = 1
  • anything else (incl. 0) stays no-go; the raw value is still preserved on Q10Zone.type

Added a test asserting the constant values and one that a no-mop (2) and a door-threshold (3) zone keep distinct types. The overlay-routing consumer in the stacked map-layers PR imports ZONE_TYPE_NO_MOP from here, so it picks up the corrected value automatically (and threshold zones will no longer be miscolored as no-mop).

I'll still gladly take your raw zone drawn → DP echoed samples to add as fixtures — particularly a real threshold and a real no-mop blob — if you're up for sharing them. Thanks for catching this. 🙏

@allenporter allenporter merged commit 0832b0a into Python-roborock:main Jun 21, 2026
7 checks passed
@allenporter

Copy link
Copy Markdown
Contributor

Thank you @tubededentifrice 👍🏼 👍🏼 👍🏼

@tubededentifrice tubededentifrice deleted the b01-grid-layers-overlays branch June 21, 2026 16:29
@andrewlyeats

Copy link
Copy Markdown
Contributor

Thanks @tubededentifrice — glad it helped, and thanks for the credit.

How these were produced: each blob below is the device's own RESTRICTED_ZONE_UP (DP 55) report, read from our MQTT client right after drawing the zone(s) in the official Roborock app — so an app draw is what provoked each one, and the base64 is exactly what the device sent, unprocessed. With nothing drawn, DP 55 reads AQAA (01 00 00, count 0). It also goes the other way: a zone written to RESTRICTED_ZONE (DP 54) comes back on the DP-55 report, and our codec re-encodes every blob here byte-for-byte (encode(parse(b)) == b).

Where these sit in the protocol: they're B01 data-points on the cloud-MQTT channel. The blobs above were issued by the device as its dpRestrictedZoneUp (55) report (_UP = the device→app direction); virtual walls and zoned-clean areas report the same way on dpVirtualWallUp (57) and dpZonedUp (59). The matching writes are the even code one lower — dpRestrictedZone (54) / dpVirtualWall (56) / dpZoned (58) — sent wrapped in dpCommon (101), e.g. {"dps": {"101": {"54": "<base64>"}}} (the same string-key COMMON envelope your #846 settings writers use). So it's an even = write / odd = _UP report pairing.

The base64 is verbatim; the hex is an annotated breakdown of those same bytes (separators, decoded (x, y), and the 20-byte zero pad shown as <20B pad>). Layout: [0x01 version][count : u8], then per zone a fixed 38-byte slot [type : u8][nverts : u8 = 0x04] + 4 × (x, y) BE int16 + 20-byte zero pad. Coords are ~5 mm units in the same world frame as the cleaning path — so they place onto the grid through the same GridCalibration you're building.

1 — drew one threshold (the app's "Set Threshold" tool, type 0x03)

base64: AQEDBACD/9sAtACrAMoApgCZ/9YAAAAAAAAAAAAAAAAAAAAAAAAAAA==
hex   : 01 01 | 03 04 | 0083 ffdb 00b4 00ab 00ca 00a6 0099 ffd6 | <20B pad>
        version=01 count=01 | type=03 nverts=04 | (131,-37)(180,171)(202,166)(153,-42)

2 — drew a No-mop Zone + a Set Threshold (types 0x02 then 0x03, count = 2) — the real no-mop you asked for, and it shows the count byte + the fixed 38-byte stride between slots:

base64: AQICBP1//Sr+SP0q/kj8Yv1//GIAAAAAAAAAAAAAAAAAAAAAAAAAAAMEAIP/3AC0AKsAyQClAJj/1gAAAAAAAAAAAAAAAAAAAAAAAAAA
hex   : 01 02
        | 02 04 fd7f fd2a fe48 fd2a fe48 fc62 fd7f fc62 <20B pad>   ← type=02 no-mop    (-641,-726)(-440,-726)(-440,-926)(-641,-926)
        | 03 04 0083 ffdc 00b4 00ab 00c9 00a5 0098 ffd6 <20B pad>   ← type=03 threshold (131,-36)(180,171)(201,165)(152,-42)

3 — drew a No-Go Zone + a No-mop Zone (types 0x00 then 0x02, count = 2) — adds the no-go (0x00) ground truth:

base64: AQIABADH/6cBM/+nATP+WwDH/lsAAAAAAAAAAAAAAAAAAAAAAAAAAAIEASr+GwHy/hsB8v1TASr9UwAAAAAAAAAAAAAAAAAAAAAAAAAA
hex   : 01 02
        | 00 04 00c7 ffa7 0133 ffa7 0133 fe5b 00c7 fe5b <20B pad>   ← type=00 no-go   (199,-89)(307,-89)(307,-421)(199,-421)
        | 02 04 012a fe1b 01f2 fe1b 01f2 fd53 012a fd53 <20B pad>   ← type=02 no-mop  (298,-485)(498,-485)(498,-685)(298,-685)

These decode cleanly through parse_zone_blob, so they should drop straight in as fixtures for the zone overlays in #848. For cross-reference against the app's own map-edit tools (we drew each and confirmed the echo): No-Go Zone → 0x00, No-mop Zone → 0x02, Set Threshold → 0x03 — all three are the same RESTRICTED_ZONE DP (54/55), even though the app presents "Set Threshold" as its own tool. The app's Invisible Wall is the separate VIRTUAL_WALL DP (56/57) below, and Carpet is a different DP again (CARPET_UP, JSON, not this binary scheme). (Matches ioBroker's areaType 2 → mop / 3 → threshold.)

One heads-up that matters for #848: parse_zone_blob's docstring lists VIRTUAL_WALL_UP (DP 57) among the blobs it handles — but on our ss07, virtual walls (the app's "Invisible Wall") use a different shape and coordinate order, so a DP-57 blob won't parse through it correctly. DP 57 is [count : u8] (no version byte, no per-record type/pad) then, per wall, two (y, x) int16 points(y, x), not (x, y). Provenance: drew one wall in the app, read back from DP 57:

base64: Aflu+cX87PoO          (= 01 | f96e f9c5 fcec fa0e)
                              count=01 | wall (y=-1682, x=-1595) -> (y=-788, x=-1522)

Fed to parse_zone_blob, the leading 01 is taken as a version and the next byte (0xf9, a coordinate) as a count of 249 records, so the blob mis-frames. Walls just need their own small decode: [count], then 8-byte (y1, x1, y2, x2) records. An empty wall list is AA== (00).

Two more in the same family, FWIW: zoned-clean rectangles ride ZONED_UP (DP 59) — same [version][count] + [type=0x01][nverts][4×(x,y)] + pad scheme as the restricted zones above; and all of these are writable, in case writers ever come up — we set zones/walls back through the string-key COMMON(101) envelope (e.g. {"101":{"54": <blob>}}) and the device echoes them on the _UP report, round-trip-verified.

If our working decode is handy as a reference (sometimes clearer than prose), it all ships in decode_map.py (v0.1.3): the virtual-wall (y, x) parse/encode is L418–449, the slot-aware restricted-zone parse/encode (the 38-byte stride walk) is L499–538, and parse_carpets (the carpet JSON) is just below.

Thanks again — happy to capture more (a specific type, more vertices, an empty → set → clear sequence, …) if it'd help.

tubededentifrice added a commit to tubededentifrice/python-roborock that referenced this pull request Jun 23, 2026
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 Python-roborock#850 thread.
tubededentifrice added a commit to tubededentifrice/python-roborock that referenced this pull request Jun 23, 2026
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 Python-roborock#850 thread.
@andrewlyeats

Copy link
Copy Markdown
Contributor

Two coordinate follow-ups — including a correction to myself. As far as I can tell none of this affected the merge (parse_zone_blob looks unwired so far), so I'm flagging it for completeness before the overlays get used — and it's all scoped to what we see on our one ss07, so treat it as a data point rather than a general rule.

Correction: I said walls (DP 57) use a different coordinate order than the zones (DP 55) — that was wrong. On our captures they're the same order; the only DP-57-vs-DP-55 difference we see is the record framing ([count] + bare 8-byte records vs [version][count] + typed/padded slots), which is what stops a wall blob parsing through parse_zone_blob.

The bit that might matter for GridCalibration: on our ss07 we're seeing the zone/wall vertices come back with x and y swapped relative to the 0201 path points — a vertex's first int16 lines up with the path's Y and its second with X ((y, x) in path terms). parse_zone_blob returns them as (x, y) (same as the trace) and the docstring places them "in the same space as the cleaning path," so on our map, feeding parsed vertices straight into the path-fit calibration lands the zones transposed. Swapping each vertex ((y, x)) before calibrating fixes it for us — framing/types/values unchanged. I can't rule out that some orientation/setting on our map makes this true for us and not in general, so it's worth a sanity check on another device before relying on it.

We hit this exact transpose in our own renderer; caught it by drawing a zone in the app and predicting its grid cell — happy to share a worked before/after when you wire the overlays in. Apologies for the mix-up!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants