Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
bd4b3f9
feat: add Q10 (B01/ss07) map support with rooms and rendered image
tubededentifrice Jun 14, 2026
1c4353e
feat: add Q10 live position parsing from 02 01 packets
tubededentifrice Jun 14, 2026
57f2639
fix: frame Q10 02 01 trace as full cleaning-session path
tubededentifrice Jun 14, 2026
d45d488
fix: allow Q10 maps without room records
tubededentifrice Jun 14, 2026
e6feafc
refactor: make Q10 map support fully push-driven
tubededentifrice Jun 15, 2026
84fc5e8
feat: decompose Q10 map into separable layers (Tier 1)
tubededentifrice Jun 14, 2026
4f9806c
feat: Q10 map calibration + path/position on map (Tiers 2-3)
tubededentifrice Jun 14, 2026
396958f
feat: decode Q10 vector overlays - no-go/no-mop zones + charger (Tier 4)
tubededentifrice Jun 14, 2026
dac32f2
feat: reuse B01 grid layers + calibration for Q7 (shared abstraction)
tubededentifrice Jun 14, 2026
fbb5c27
feat: decode Q10 carpets from the map packet tail
tubededentifrice Jun 14, 2026
f425757
fix: don't wipe Q10 overlays on partial status updates
tubededentifrice Jun 14, 2026
fc11176
fix: correct Q10 map orientation and identify/apply erase zones
tubededentifrice Jun 14, 2026
4b0ca89
fix: preserve Q10 map overlays after reparse
tubededentifrice Jun 14, 2026
6efe266
fix: adapt Q10 map-layers CLI + overlay routing to push-driven map API
tubededentifrice Jun 15, 2026
74c88e9
Merge origin/main into q10-map-layers
tubededentifrice Jun 23, 2026
48d70c9
feat: derive Q10 calibration origin from the 0101 grid-frame header
tubededentifrice Jun 23, 2026
1a2826e
fix: decode Q10 virtual walls (DP 57) with their own frame
tubededentifrice Jun 23, 2026
2a6059e
test: add live RDC ss07 capture for the DP-57 virtual-wall decoder
tubededentifrice Jun 23, 2026
fa799d4
test: add live RDC ss07 capture for the DP-55 no-go zone decoder
tubededentifrice Jun 23, 2026
1d6956f
fix: decode Q10 trace heading from the 0201 SLAM field (bytes 10-11)
tubededentifrice Jun 24, 2026
cea9c74
fix: decode Q10 virtual walls (DP 57) in the same axis order as no-go…
tubededentifrice Jun 24, 2026
c6285fa
test: add ground-truthed R1 mixed-orientation virtual-wall capture
tubededentifrice Jun 24, 2026
3a03eb3
fix: widen Q10 calibration resolution range to bracket the real 20 pa…
tubededentifrice Jun 24, 2026
b980853
docs: pin Q10 path-unit scale (2.5mm) and ground-truth the heading co…
tubededentifrice Jun 24, 2026
3e9e34d
Merge remote-tracking branch 'origin/main' into q10-map-layers
tubededentifrice Jun 26, 2026
496474c
refactor: reconcile Q10 map dispatch onto #847's typed-message model
tubededentifrice Jun 27, 2026
52e6f1e
feat: decode the Q10 carpet mask from the map-packet tail
tubededentifrice Jun 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 97 additions & 3 deletions roborock/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -607,14 +607,19 @@ async def _await_q10_map_push(
timeout: float = _Q10_MAP_PUSH_TIMEOUT,
allow_cached_on_timeout: bool = False,
) -> bool:
"""Nudge a Q10 to push its map/trace and wait for a fresh update.
"""Nudge a Q10 to push its map/trace and wait until ``predicate`` holds.

The Q10 map API is entirely push-driven: there is no synchronous get-map
request. A ``dpRequestDps`` causes the device to publish a ``MAP_RESPONSE``,
which the device's subscribe loop feeds into the map trait. Here we register
an update listener, send the request, and wait for a newly pushed update to
satisfy ``predicate``. Returns whether it did within ``timeout``.
an update listener, send the request, and wait for the pushed data to satisfy
``predicate``. Returns whether it did within ``timeout``. When the predicate
already holds against cached content we return immediately without nudging.
If ``allow_cached_on_timeout`` is set, a timeout still returns ``True`` when
the predicate holds against the previously cached content.
"""
if predicate():
return True
loop = asyncio.get_running_loop()
updated: asyncio.Future[None] = loop.create_future()

Expand Down Expand Up @@ -662,6 +667,59 @@ async def map_image(ctx, device_id: str, output_file: str):
click.echo("No map image content available.")


@session.command()
@click.option("--device_id", required=True)
@click.option("--output-dir", default=None, help="If set, write one transparent PNG per layer here.")
@click.pass_context
@async_command
async def q10_map_layers(ctx, device_id: str, output_dir: str | None):
"""List the Q10 map's separable layers (background/wall/floor/per-room).

With --output-dir, also exports each layer as a transparent PNG that can be
stacked in a frontend (background, then floor, then walls, then each room).
"""
import os

context: RoborockContext = ctx.obj
device_manager = await context.get_device_manager()
device = await device_manager.get_device(device_id)
if device.b01_q10_properties is None:
click.echo("Feature not supported by device")
return
properties = device.b01_q10_properties
await _await_q10_map_push(properties, lambda: properties.map.layers is not None)
layers = properties.map.layers
if layers is None:
click.echo("No map layers available.")
return

summary = {
"size": {"width": layers.width, "height": layers.height},
"class_counts": layers.class_counts,
"rooms": [
{"id": r.id, "name": r.name, "pixel_count": r.pixel_count, "bbox": list(r.bbox)} for r in layers.rooms
],
}
click.echo(dump_json(summary))

if output_dir:
os.makedirs(output_dir, exist_ok=True)
exports = {
"background": layers.render_class("background", (210, 210, 215, 255), scale=2),
"floor": layers.render_class("floor", (70, 170, 95, 200), scale=2),
"wall": layers.render_class("wall", (20, 20, 25, 255), scale=2),
}
for name, png in exports.items():
with open(os.path.join(output_dir, f"layer_{name}.png"), "wb") as f:
f.write(png)
for room in layers.rooms:
png = layers.render_room(room.id, (90, 140, 220, 200), scale=2)
safe = "".join(c if c.isalnum() else "_" for c in room.name) or f"room{room.id}"
with open(os.path.join(output_dir, f"room_{room.id}_{safe}.png"), "wb") as f:
f.write(png)
click.echo(f"Wrote {3 + len(layers.rooms)} layer PNGs to {output_dir}")


@session.command()
@click.option("--device_id", required=True)
@click.option("--include_path", is_flag=True, default=False, help="Include path data in the output.")
Expand Down Expand Up @@ -721,6 +779,42 @@ async def q10_position(ctx, device_id: str, include_path: bool):
click.echo(dump_json(summary))


@session.command()
@click.option("--device_id", required=True)
@click.option("--output-file", required=True, help="Path to save the map image with the path drawn.")
@click.pass_context
@async_command
async def q10_map_with_path(ctx, device_id: str, output_file: str):
"""Render the Q10 map with the current cleaning path + robot position drawn.

Needs the robot to be actively cleaning (the path/calibration come from the
live trace). Fetches the map and the path, solves the world<->pixel
calibration, and writes the annotated PNG.
"""
context: RoborockContext = ctx.obj
device_manager = await context.get_device_manager()
device = await device_manager.get_device(device_id)
if device.b01_q10_properties is None:
click.echo("Feature not supported by device")
return
properties = device.b01_q10_properties
map_trait = properties.map
await _await_q10_map_push(properties, lambda: map_trait.image_content is not None)
got_path = await _await_q10_map_push(properties, lambda: bool(map_trait.path))
if not got_path:
click.echo("No live path available (the robot only reports its path while cleaning).")
return
try:
image = map_trait.render_path_on_map()
except RoborockException as err:
click.echo(f"Could not render path on map: {err}")
return
with open(output_file, "wb") as f:
f.write(image)
cal = map_trait.calibration
click.echo(f"Saved map with {len(map_trait.path)}-point path to {output_file} (calibration: {cal})")


@session.command()
@click.option("--device_id", required=True)
@click.pass_context
Expand Down
5 changes: 5 additions & 0 deletions roborock/data/b01_q10/b01_q10_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ class Q10Status(RoborockBase):
cleaning_progress: int | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_PROGRESS})
fault: int | None = field(default=None, metadata={"dps": B01_Q10_DP.FAULT})

# Raw base64 map-overlay blobs (decoded by roborock.map.b01_q10_overlays).
restricted_zone_up: str | None = field(default=None, metadata={"dps": B01_Q10_DP.RESTRICTED_ZONE_UP})
virtual_wall_up: str | None = field(default=None, metadata={"dps": B01_Q10_DP.VIRTUAL_WALL_UP})
zoned_up: str | None = field(default=None, metadata={"dps": B01_Q10_DP.ZONED_UP})

# Additional state reported in the device's full status dump.
clean_line: YXCleanLine | None = field(default=None, metadata={"dps": B01_Q10_DP.CLEAN_LINE})
carpet_clean_type: YXCarpetCleanType | None = field(default=None, metadata={"dps": B01_Q10_DP.CARPET_CLEAN_TYPE})
Expand Down
8 changes: 8 additions & 0 deletions roborock/devices/traits/b01/q10/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,14 @@ def _handle_message(self, message: Q10Message) -> None:
for trait in self._updatable_traits:
trait.update_from_dps(message.dps)

# Feed the map's vector-overlay data points (no-go zones / virtual
# walls) to the map trait so they are decoded as they arrive.
if B01_Q10_DP.RESTRICTED_ZONE_UP in message.dps or B01_Q10_DP.VIRTUAL_WALL_UP in message.dps:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This indicates to me the DPS fields on Status should probably live on the map trait? It seems like the update_from_dps API could potentially handle these types of calls.

self.map.load_overlays(
restricted_zone_up=message.dps.get(B01_Q10_DP.RESTRICTED_ZONE_UP),
virtual_wall_up=message.dps.get(B01_Q10_DP.VIRTUAL_WALL_UP),
)


def create(channel: MqttChannel) -> Q10PropertiesApi:
"""Create traits for B01 devices."""
Expand Down
Loading
Loading