Skip to content

Add Q7 map_content support and room segment cleaning helper#774

Open
arduano wants to merge 6 commits intoPython-roborock:mainfrom
arduano:molty/q7-segment-clean-b01
Open

Add Q7 map_content support and room segment cleaning helper#774
arduano wants to merge 6 commits intoPython-roborock:mainfrom
arduano:molty/q7-segment-clean-b01

Conversation

@arduano
Copy link

@arduano arduano commented Feb 28, 2026

Summary

  • add Q7PropertiesApi.clean_segments(room_ids) helper for write-dependent room cleaning
  • add B01/Q7 map decode pipeline and SCMap parse/render support
  • add Q7 map_content trait for map image bytes
  • update map fetch flow to read current map_id via service.get_map_list and fetch payload via service.upload_by_mapid (map_id param)

Why

This provides a clean, typed foundation for both:

  • read-only Q7 telemetry/map retrieval, and
  • write-dependent segment cleaning,
    without relying on HA-side decode logic.

Testing

Unit tests (repo-local .venv, nix-shell Python 3.12)

  • pytest -q tests/map/test_b01_map_parser.py tests/devices/traits/b01/q7/test_init.py tests/devices/traits/b01/q7/test_clean_summary.py
  • result: 27 passed

Read-only live checks against Q7 (no cleaning/write actions)

  • queried telemetry props successfully (status, wind, cleaning_time)
  • fetched map payload and rendered PNG via map_content.refresh()
  • verified PNG bytes and magic header (\x89PNG...)

Lint

  • ruff check on modified parser/trait/tests passed

Copilot AI review requested due to automatic review settings February 28, 2026 03:38
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a typed helper on the Q7 (B01) trait API to start room/segment cleaning by routing through the existing service.set_room_clean command, while leaving existing start/pause/stop helpers unchanged.

Changes:

  • Introduce Q7PropertiesApi.clean_segments(room_ids) to start ROOM-type cleaning via SET_ROOM_CLEAN.
  • Use existing CleanTaskTypeMapping / SCDeviceCleanParam mappings to build the command payload.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 90 to 99
async def clean_segments(self, room_ids: list[int]) -> None:
"""Start segment/room cleaning for the given room ids."""
await self.send(
command=RoborockB01Q7Methods.SET_ROOM_CLEAN,
params={
"clean_type": CleanTaskTypeMapping.ROOM.code,
"ctrl_value": SCDeviceCleanParam.START.code,
"room_ids": room_ids,
},
)
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

This new public helper isn’t covered by the existing Q7PropertiesApi unit tests (there are tests for start/pause/stop). Please add a test that calls clean_segments([...]) and asserts the published RPC method/params, similar to test_q7_api_start_clean, to prevent regressions in the command payload.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

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

Openclaw: Added coverage in tests/devices/traits/b01/q7/test_init.py (test_q7_api_clean_segments) asserting RPC method and payload for clean_segments([...]).

@greptile-apps
Copy link

greptile-apps bot commented Feb 28, 2026

Greptile Summary

This PR adds comprehensive Q7 map support with B01 protocol decoding and room-based segment cleaning:

  • Implements Q7PropertiesApi.clean_segments(room_ids) for room-based cleaning commands
  • Adds complete B01 map decode pipeline (multi-layer base64/encryption/compression handling)
  • Implements SCMap protobuf parsing with varint support for map dimensions and room data
  • Adds Q7MapContentTrait.refresh() that fetches current map via service.get_map_list + service.upload_by_mapid
  • Includes PNG rendering with room coloring
  • Comprehensive test coverage (27 tests) with real fixture data and edge cases
  • Maintains backwards compatibility with optional map_content trait

The implementation is well-structured with proper error handling, though there's a minor style inconsistency in send_map_command where future.done() checks are missing before setting results (lines 149, 154).

Confidence Score: 4/5

  • This PR is safe to merge with only minor style improvements needed
  • Score reflects comprehensive test coverage (27 tests), clean architecture, proper error handling, and backwards compatibility. One minor style issue in exception handling doesn't affect functionality.
  • Pay attention to roborock/devices/rpc/b01_q7_channel.py for the missing future.done() checks

Important Files Changed

Filename Overview
roborock/map/b01_map_parser.py Adds comprehensive B01/Q7 map decoding pipeline with protobuf parsing, multi-layer decryption, and PNG rendering. Well-tested with fixtures.
roborock/devices/rpc/b01_q7_channel.py Adds send_map_command with per-channel locking for serialized map requests. Minor: missing future.done() checks before setting results (lines 149, 154).
roborock/devices/traits/b01/q7/map_content.py New Q7 map content trait with clean map_id extraction logic and fallback to first map. Well-structured refresh flow.
roborock/devices/traits/b01/q7/init.py Adds clean_segments helper and optional map_content trait with backwards-compatible constructor.
tests/devices/traits/b01/q7/test_init.py Comprehensive tests for clean_segments and map_content.refresh() including edge cases (empty map_list, fallback behavior).
tests/map/test_b01_map_parser.py Unit tests for B01 map parser covering parse, render, and round-trip decode scenarios with real fixture data.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Q7PropertiesApi
    participant Q7MapContentTrait
    participant send_map_command
    participant MqttChannel
    participant Device

    Client->>Q7PropertiesApi: map_content.refresh()
    Q7PropertiesApi->>Q7MapContentTrait: refresh()
    
    Note over Q7MapContentTrait: Step 1: Get map list
    Q7MapContentTrait->>send_map_command: send_decoded_command(GET_MAP_LIST)
    send_map_command->>MqttChannel: publish(request)
    MqttChannel->>Device: MQTT publish
    Device-->>MqttChannel: JSON response
    MqttChannel-->>send_map_command: decoded response
    send_map_command-->>Q7MapContentTrait: {map_list: [{id, cur}]}
    
    Note over Q7MapContentTrait: Step 2: Extract current map_id
    Q7MapContentTrait->>Q7MapContentTrait: _extract_current_map_id()
    
    Note over Q7MapContentTrait: Step 3: Fetch map payload
    Q7MapContentTrait->>send_map_command: send_map_command(UPLOAD_BY_MAPID)
    Note over send_map_command: Lock acquired (serialized)
    send_map_command->>MqttChannel: publish(request)
    MqttChannel->>Device: MQTT publish
    Device-->>MqttChannel: MAP_RESPONSE payload
    MqttChannel-->>send_map_command: raw encrypted bytes
    Note over send_map_command: Lock released
    send_map_command-->>Q7MapContentTrait: raw_payload
    
    Note over Q7MapContentTrait: Step 4: Decode pipeline
    Q7MapContentTrait->>b01_map_parser: decode_b01_map_payload()
    Note over b01_map_parser: - Base64 decode<br/>- AES decrypt (local_key + map_key)<br/>- Decompress (zlib)
    b01_map_parser-->>Q7MapContentTrait: inflated SCMap protobuf
    
    Note over Q7MapContentTrait: Step 5: Parse and render
    Q7MapContentTrait->>b01_map_parser: parse_scmap_payload()
    b01_map_parser-->>Q7MapContentTrait: B01MapData (dimensions, grid, rooms)
    Q7MapContentTrait->>b01_map_parser: render_map_png()
    b01_map_parser-->>Q7MapContentTrait: PNG bytes
    
    Q7MapContentTrait-->>Client: B01MapContent (image_content, rooms)
Loading

Last reviewed commit: aea98ed

@arduano
Copy link
Author

arduano commented Feb 28, 2026

Hey, just a draft PR I'm scrapping together with my openclaw instance
Keeping it as draft mode until I truly test it and review it myself for real

This is for a Q7T+ that a family member recently purchased, and I was sad to find out that their purchase isn't compatible with my HASS so I decided to go fix it myself

@Lash-L
Copy link
Collaborator

Lash-L commented Feb 28, 2026

You'll need something similar for getting the room list as well. Haven't dove into it myself yet. Point your openclaw instance to look at honey Roborock code and it should be able to find the logic there. I think he has it implemented there

@arduano arduano changed the title Add Q7 room/segment cleaning helper Add Q7 map_content support and room segment cleaning helper Feb 28, 2026
@arduano arduano marked this pull request as draft February 28, 2026 04:36
@arduano
Copy link
Author

arduano commented Feb 28, 2026

@Lash-L what do you mean by "honey Roborock code", do you mean Homey?

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 10 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from PIL import Image
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

This module imports PIL.Image, but Pillow is not a direct dependency of this project (it’s currently only present via transitive dependencies in uv.lock). Relying on a transitive dependency can break installs if upstream dependency graphs change.

Add Pillow as an explicit runtime dependency (or avoid using Pillow here) so the import is guaranteed to be available for end users.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

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

Openclaw: Addressed. Pillow is now an explicit runtime dependency in pyproject.toml and uv.lock.

Comment on lines 40 to 44
def __init__(self, channel: MqttChannel, *, local_key: str, serial: str, model: str) -> None:
"""Initialize the B01Props API."""
self._channel = channel
self.clean_summary = CleanSummaryTrait(channel)
self.map_content = Q7MapContentTrait(channel, local_key=local_key, serial=serial, model=model)
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

Q7PropertiesApi.__init__ (and create) now require local_key, serial, and model, which is a breaking change for any callers instantiating Q7PropertiesApi(channel) directly (the class is exported in __all__).

If backwards compatibility matters, consider keeping the old signature by making these parameters optional and either (a) only enabling map_content when they’re provided, or (b) accepting a single device-info object and/or providing an alternate constructor for map support.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

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

Openclaw: Addressed for backward compatibility. Q7PropertiesApi.init/create now accept optional local_key/serial/model; map support is enabled only when provided. Added a constructor-compatibility test for direct callers.

@@ -1,7 +1,13 @@
"""Module for Roborock map related data classes."""
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

The module docstring says this is a module for map-related data classes, but this file now also exports parsing/rendering functions (decode_b01_map_payload, parse_scmap_payload, render_map_png). Consider updating the docstring to reflect the broader surface area.

Suggested change
"""Module for Roborock map related data classes."""
"""Utilities and data classes for working with Roborock maps, including parsing and rendering functions."""

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

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

Openclaw: Updated the module docstring to reflect broader map utilities (data classes plus parsing/rendering helpers).

Comment on lines 114 to 116
if response_message.protocol == RoborockMessageProtocol.MAP_RESPONSE and response_message.payload:
future.set_result(response_message.payload)
return
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

send_map_command resolves the waiting future on the first MAP_RESPONSE seen, without correlating it to request_message.msg_id (or otherwise confirming it’s the response for this request). If multiple map uploads overlap (or any other MAP_RESPONSE arrives on the same subscription), this can complete with the wrong payload.

Consider correlating by first waiting for the matching RPC ack (same msgId / code==0) and only then accepting the next MAP_RESPONSE, or serializing map requests with a per-channel lock so only one send_map_command is in-flight at a time.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

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

Openclaw: Addressed by serializing B01 map commands per channel (lock) and keeping msgId-aware response handling to prevent cross-wired payloads under overlap.

@arduano arduano marked this pull request as ready for review February 28, 2026 06:04
Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

12 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment on lines 149 to 154
future.set_exception(RoborockException(f"B01 command failed with code {code} ({request_message})"))
return
data = inner.get("data")
if isinstance(data, dict) and isinstance(data.get("payload"), str):
try:
future.set_result(bytes.fromhex(data["payload"]))
Copy link

Choose a reason for hiding this comment

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

Missing if not future.done() checks before setting exception/result. While unlikely, if multiple dps values match the same msgId, this could raise InvalidStateError. Compare with send_decoded_command which checks before setting (lines 73-74, 79-80, 84-85).

Suggested change
future.set_exception(RoborockException(f"B01 command failed with code {code} ({request_message})"))
return
data = inner.get("data")
if isinstance(data, dict) and isinstance(data.get("payload"), str):
try:
future.set_result(bytes.fromhex(data["payload"]))
code = inner.get("code", 0)
if code != 0:
if not future.done():
future.set_exception(RoborockException(f"B01 command failed with code {code} ({request_message})"))
return
data = inner.get("data")
if isinstance(data, dict) and isinstance(data.get("payload"), str):
try:
if not future.done():
future.set_result(bytes.fromhex(data["payload"]))
return
except ValueError:
pass

@arduano
Copy link
Author

arduano commented Feb 28, 2026

Openclaw: Thanks for the review. I addressed the future.done() style concern in send_map_command by guarding all completion paths (set_result/set_exception) and pushed commit 3a0b944 to this PR branch. Also re-ran targeted Q7 map tests after the change (4 passed).

@arduano
Copy link
Author

arduano commented Feb 28, 2026

@Lash-L properly ready for review now
Just heads up, anything that's Openclaw: or 🦎 is AI. Everything else is me

I looked through the code, don't see any red flags personally. I verified that it does work on my vacuum, pulling the map, coords, rooms, etc, and being able to queue commands such as vacuuming a specific room

@arduano
Copy link
Author

arduano commented Feb 28, 2026

I just connected it to my home assistant (manually build the hass container with this new PR as a dependency) and I can now view the map and send the vacuum to clean specific segments.

The only thing I'm unsure about is map feature rendering. Am I supposed to make this PR also render things like the vacuum location into the map png, or is that handled in hass? Right now it just sends the plain map

Copy link
Contributor

@allenporter allenporter left a comment

Choose a reason for hiding this comment

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

Good progress here. Some early thoughts

cross-wire responses between callers.
"""

async with _get_map_command_lock(mqtt_channel):
Copy link
Contributor

Choose a reason for hiding this comment

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

Please move the asyncio.Lock to the trait, which is one per device. We don't need to do this indirectly via a map to the channel as we can just do it there before calling into this map function.

future.set_result(response_message.payload)
return

try:
Copy link
Contributor

Choose a reason for hiding this comment

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

Does this happen in practice that we get a normal protocol response back or is this speculative? I'm wondering if this could have a high level comment describing the scenarios in which we observe this happening. Second, i think this is complex enough that likely we'd want to reuse the common parts across both protocols, in a way that is easy to read.

return None


def decode_b01_map_payload(raw_payload: bytes, *, local_key: str, serial: str, model: str) -> bytes:
Copy link
Contributor

Choose a reason for hiding this comment

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

Please review https://github.com/Python-roborock/python-roborock/blob/df84567fe7d5fc02409185a8e71fd3996093139e/docs/DEVICES.md#layer-responsibilities

Problem: This code is combining map decoding with protocol decoding and these responsibilities should be split. e.g. review how the v1 code separates the protocol encoding/decoding from map encoding. e.g.

def create_map_response_decoder(security_data: SecurityData) -> Callable[[RoborockMessage], MapResponse | None]:

or create_v1_channel.

The idea is we shouldn't need to be passing around local keys everywhere. Make a separate rpc channel object responsible for decoding the bytes. Then the map trait can get decoded bytes and pass them to a map content parser.

You can also review how existing encryption code is structured in protocol.py and protocols/

Copy link
Contributor

Choose a reason for hiding this comment

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

Lets put this in tests/map/testdata/


from roborock.devices.rpc.b01_q7_channel import send_decoded_command, send_map_command
from roborock.devices.traits import Trait
from roborock.devices.traits.v1.map_content import MapContent
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's not directly depend on the traits of another device type. For now, create a new object with the fields we need here rather than reusing this and we can decide to reuse in the future. The protocols are different enough that I don't think we'll have calling code or anything that will treat these map objects the same.

async def refresh(self) -> B01MapContent:
map_list_response = await send_decoded_command(
self._channel,
Q7RequestMessage(dps=10000, command=RoborockB01Q7Methods.GET_MAP_LIST, params={}),
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we extract a named constant for 10000?

self._model = model

async def refresh(self) -> B01MapContent:
map_list_response = await send_decoded_command(
Copy link
Contributor

Choose a reason for hiding this comment

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

In v1 we have a separate trait for holding on to the map list and current map MapsTrait. I assume we'll want the other content here also?

We decided to separate them in v1 just because there was a lot of existing code to unwind and introduced the Home object to help with caching map content across different maps, but not sure we need that here yet.

Can you review the existing MapsTrait / HomeTrait / MapContentTrait for v1 and consider what would be a reasonable first step to take with that in mind? I do worry that getting the current_map logic wrong is harder to fix later, but we can also do something simple for now. Basically i think it'd be good to at least keep the map list somewhere.

@allenporter
Copy link
Contributor

The only thing I'm unsure about is map feature rendering. Am I supposed to make this PR also render things like the vacuum location into the map png, or is that handled in hass? Right now it just sends the plain map

Today this happens in roborock/map/map_parser.py via another 3rd party package. I wonder if the format is the same or different?

@Lash-L
Copy link
Collaborator

Lash-L commented Feb 28, 2026

@Lash-L what do you mean by "honey Roborock code", do you mean Homey?

Yes I did mean Homey.

@Lash-L
Copy link
Collaborator

Lash-L commented Feb 28, 2026

Today this happens in roborock/map/map_parser.py via another 3rd party package. I wonder if the format is the same or different?

Pretty much completely different.

We could theoretically just make a new instance of MapDataParser to make all of the objects use the same functions, objects, etc. as input, but i'm not sure if that makes sense or not. The biggest benefit of doing so is that it would work with https://github.com/PiotrMachowski/lovelace-xiaomi-vacuum-map-card which is always one of the biggest requests with vacuum maps.

I'm also not sure if it should live here or in the 3rd party library.

My gut is thinking it should live here so that we have more control over it. We could also probably transfer it into the python-roborock org to give us more control, but i'm not sure if the abstraction is worth it or not.

We are also the only users of the 3rd party library (for roborock).

As well Q10 will be completely different than this as well, so we have another obstacle there.

@arduano
Copy link
Author

arduano commented Feb 28, 2026

Happy to mess around with the code further if needed

@Lash-L
Copy link
Collaborator

Lash-L commented Mar 1, 2026

@allenporter

What are your thoughts regarding the map parser? I'm kind of leaning towards it living in this library, but it does feel a little weird having one device type having a map in a different library than the rest. But I think it makes sense? I do think it makes sense to still utilize the same base object if possible

@allenporter
Copy link
Contributor

@allenporter

What are your thoughts regarding the map parser? I'm kind of leaning towards it living in this library, but it does feel a little weird having one device type having a map in a different library than the rest. But I think it makes sense? I do think it makes sense to still utilize the same base object if possible

Yeah, I agree with your proposals of just doing it here and/or copying the other library in here. My impression is you are the main person making changes to the library anyway. It's kind of weird how its split across two other packages also, and is a bit overly abstracted.

@Lash-L
Copy link
Collaborator

Lash-L commented Mar 1, 2026

Yeah, I agree with your proposals of just doing it here and/or copying the other library in here. My impression is you are the main person making changes to the library anyway. It's kind of weird how its split across two other packages also, and is a bit overly abstracted.

Technically it's split over like 5 different libraries with a different one per each brand, but yes i agree.

Okay @arduano I think action items from here:

  • Build this map parser on top of the MapDataParser so that we can share the configuration objects.
  • Resolve Allen's comments
  • Ideally split this up into multiple PRs. Focus on getting things done incrementally. i.e. first focus on just getting the commands working for getting the map and segments and test, then focus on actually doing something with the map (i.e. parsing it)
  • Add a bit more commenting around the map parsing logic. (Don't go overboard), but it can be a bit difficult to follow some of the complex stuff. Comments don't need to describe the code, but rather describe what is happening if that makes sense.

Feel free to add any allen

@arduano
Copy link
Author

arduano commented Mar 1, 2026

Resolve Allen's comments

Which ones? Or do you mean the ones in the hass-core repo. Should I be migrating anything my AI did in that PR over to this PR?

I'll address later today

@Lash-L
Copy link
Collaborator

Lash-L commented Mar 1, 2026

Resolve Allen's comments

Which ones? Or do you mean the ones in the hass-core repo. Should I be migrating anything my AI did in that PR over to this PR?

I'll address later today

The ones he made here during this review.

I haven't looked at the core PR, but the library should contain all complex logic. It should be very easy and clear to utilize from core if we design it well

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.

4 participants