Skip to content

q7: add map_content trait for B01 current map#785

Merged
allenporter merged 19 commits intoPython-roborock:mainfrom
arduano:leo/q7-map-content-followup
Mar 31, 2026
Merged

q7: add map_content trait for B01 current map#785
allenporter merged 19 commits intoPython-roborock:mainfrom
arduano:leo/q7-map-content-followup

Conversation

@arduano
Copy link
Copy Markdown
Contributor

@arduano arduano commented Mar 15, 2026

Summary

Add Q7/B01 map_content support on top of the already-merged raw map transport work.

This PR:

  • adds a Q7 map_content trait with the same basic refresh/cached-fields pattern used by v1
  • wires B01/Q7 trait creation through device_manager with access to the device/product metadata needed for map decoding
  • adds a minimal internal B01 map parser that:
    • decodes the raw MAP_RESPONSE payload
    • parses the inflated SCMap payload with the protobuf runtime
    • renders a PNG image
    • stores observed room names in map_data.additional_parameters["room_names"]
  • adds parser + trait tests and fixture data

Design intent

The goal here is to keep the new work aligned with the existing codebase shape rather than inventing a separate parser/integration framework:

  • transport stays in the existing Q7 map trait
  • parsed map access lives in a dedicated map_content trait
  • refresh() performs I/O and populates cached fields
  • parsing internals stay private/minimal
  • the runtime decode path is narrowed to the live-observed payload format

Validation

  • full pytest -q398 passed, 1 warning
  • full ruff check . → clean
  • focused parser + trait tests rerun after the final parser/comment/lint fixes:
    • tests/map/test_b01_map_parser.py
    • tests/devices/traits/b01/q7/test_map_content.py
    • result: 7 passed
  • live read-only sanity check on a real Q7T+ account/device:
    • device model: roborock.vacuum.sc05
    • map fetch/decode/render succeeded
    • rendered PNG bytes: 17457
    • raw payload bytes: 35584
    • room names discovered: 10..19 / room1..room10

Notes

  • The inner SCMap blob uses protobuf-style message encoding; this PR now uses the protobuf runtime with a small local runtime descriptor for the subset of fields consumed here.
  • Repo-wide mypy is not currently clean on main; this branch does not add new parser-specific mypy failures, but I did not include unrelated mypy cleanups in this PR.

Copilot AI review requested due to automatic review settings March 15, 2026 02:07
Copy link
Copy Markdown
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 parsed “map content” support for B01/Q7 devices by introducing a dedicated map_content trait layered on top of the existing raw map transport, plus a minimal SCMap decoder/parser that can render a PNG and extract room names.

Changes:

  • Introduces B01MapParser to decode MAP_RESPONSE payloads, parse inflated SCMap fields, and render a PNG + MapData.
  • Adds MapContentTrait for B01/Q7 to refresh/fetch current map payload via MapTrait and expose cached image_content, map_data, and raw bytes.
  • Wires Q7 trait creation through device_manager with device/product metadata; adds tests + fixtures.

Reviewed changes

Copilot reviewed 8 out of 9 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/map/testdata/raw-mqtt-map301.bin.inflated.bin.gz Adds gzipped inflated SCMap fixture payload.
tests/map/test_b01_map_parser.py Tests for payload decode + PNG render + room name extraction.
tests/map/debug_b01_scmap.py Standalone developer helper to inspect/pretty-dump SCMap-like payloads.
tests/devices/traits/b01/q7/test_map_content.py Trait-level tests for refresh caching + error handling + missing metadata.
tests/devices/traits/b01/q7/conftest.py Updates Q7 test fixtures to provide device/product metadata to the API.
roborock/map/b01_map_parser.py Implements B01/Q7 map decode + minimal wire parsing + occupancy PNG rendering.
roborock/devices/traits/b01/q7/map_content.py Adds B01/Q7 map_content trait with refresh/cached-fields pattern.
roborock/devices/traits/b01/q7/init.py Wires map_content into Q7PropertiesApi; updates factory to require device/product.
roborock/devices/device_manager.py Passes product/device into B01 Q7 trait creation.

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

@arduano arduano force-pushed the leo/q7-map-content-followup branch 2 times, most recently from a81db61 to 67fc042 Compare March 15, 2026 02:15
@arduano arduano force-pushed the leo/q7-map-content-followup branch from 67fc042 to 487778a Compare March 15, 2026 02:18
@arduano arduano marked this pull request as draft March 15, 2026 03:26
@arduano
Copy link
Copy Markdown
Contributor Author

arduano commented Mar 15, 2026

Setting this to draft until I push protobuf stuff

@arduano arduano force-pushed the leo/q7-map-content-followup branch 4 times, most recently from 8ef5515 to 2c839e0 Compare March 15, 2026 06:36
@arduano arduano force-pushed the leo/q7-map-content-followup branch from 2c839e0 to 2d73fd1 Compare March 15, 2026 06:39
@arduano arduano marked this pull request as ready for review March 15, 2026 07:10
Copy link
Copy Markdown
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.

Great, i see this was updated to use some protobuf libraries, but can this use a real .proto for defining the format and/or parsing?

@arduano
Copy link
Copy Markdown
Contributor Author

arduano commented Mar 16, 2026

ah I wasn't sure from our last conversation if you explicitly preferred a pure code approach, must've misunderstood

I'll adjust today

@allenporter
Copy link
Copy Markdown
Contributor

ah I wasn't sure from our last conversation if you explicitly preferred a pure code approach, must've misunderstood

I'll adjust today

yeah i think i said something vague over chat -- but seeing that there is a very large amount of code manually constructing the messages, it seems preferred to do something with less code.

@arduano arduano force-pushed the leo/q7-map-content-followup branch from 6b3ec67 to bedf379 Compare March 16, 2026 05:21
@arduano arduano requested a review from Lash-L March 19, 2026 03:55
For B01/Q7 devices, the underlying raw map payload is retrieved via `MapTrait`.
"""

from __future__ import annotations
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.

Remove since this will mess with types and is no longer needed for modern code.

@@ -0,0 +1,70 @@
// Checked-in B01/Q7 SCMap schema for the generated runtime protobuf module.
// Regenerate the checked-in Python module after edits with:
// python -m grpc_tools.protoc -I./roborock/map/proto --python_out=./roborock/map/proto roborock/map/proto/b01_scmap.proto
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.

We're getting closer! My impression though is that you can output the _pb.py files as described in https://protobuf.dev/reference/python/python-generated/ so you get much more than just a descriptor.

Since you have DevicePointInfo defined in .proto you should be able to get fully generated python code for parsing that message and not need _ScPointMessage.

@arduano arduano force-pushed the leo/q7-map-content-followup branch from 2dfd139 to 5d19def Compare March 23, 2026 03:00
@arduano arduano force-pushed the leo/q7-map-content-followup branch from 5d19def to 99a63e6 Compare March 23, 2026 03:02
@arduano
Copy link
Copy Markdown
Contributor Author

arduano commented Mar 23, 2026

How does it look now?

@arduano arduano requested a review from allenporter March 23, 2026 03:12
Copy link
Copy Markdown
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.

That looks like the right direction, but doesn't appear to be done given there are many other messages still in the manually specified format.

I don't see the actual generated classes, but maybe i'm missing something since the tests pass, or maybe we're not running the code in tests...



@dataclass(frozen=True)
class _ScPoint:
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.

are these still needing to move to generated messages?

@Stanley083
Copy link
Copy Markdown

That looks like the right direction, but doesn't appear to be done given there are many other messages still in the manually specified format.

I don't see the actual generated classes, but maybe i'm missing something since the tests pass, or maybe we're not running the code in tests...

I'm following this work so closely 😔

@arduano
Copy link
Copy Markdown
Contributor Author

arduano commented Mar 24, 2026

Hey, sorry I was addressing this late after work from my phone. It's late after work on my phone again so I hope this AI change is correct, it seems to pass tests and stuff

I'll have a second pass later whenever I get a chance, let me know if it looks good if you get to it first though

@arduano arduano requested a review from allenporter March 29, 2026 06:23
Copy link
Copy Markdown
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.

Look fantastic. There is some additional cleanup I would like to see on code separation between the protocol layers, but I think we can solve that prat in a follow up PR. I just have a few comments around the encoding and handling handling that I think would to settle first, then we should be good to merge from my PoV.

Thank you for your incredible work on this contribution. Contributing capabilities like this is huge.


from .map_parser import ParsedMapData

_B64_CHARS = set(b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=")
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.

can import string then do
_B64_CHARS = set((string.ascii_letters + string.digits + "+/=").encode())

but also maybe just consider if the check for these is really needed as b64 decoding may just fail anyway with a similar error message without the check?

raise RoborockException("Unexpected encrypted B01 map payload length")

map_key = _derive_map_key(serial, model)
decrypted_hex = AES.new(map_key, AES.MODE_ECB).decrypt(encrypted_payload)
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.

Does the existing roborock.protocol.Utils work for this? or if not, can that be modified/extended? I think its nice to have all the AES logic for all platforms in the same place.


def parse(self, raw_payload: bytes, *, serial: str, model: str) -> ParsedMapData:
"""Parse a raw MAP_RESPONSE payload and return a PNG + MapData."""
inflated = _decode_b01_map_payload(raw_payload, serial=serial, model=model)
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.

Similar to v1 lets separate the lower level b01 protocol decoding from the map level decoding and stick this part in roborock.protocols

Or please add a TODO and I can move it?

You can see v1 an example:

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

Though i think its fine to level the protocol buffer parts here.

Comment on lines +121 to +139
def _parse_proto(blob: bytes, message: Message, *, context: str) -> None:
try:
message.ParseFromString(blob)
except DecodeError as err:
raise RoborockException(f"Failed to parse {context}") from err


def _decode_map_data_bytes(value: bytes) -> bytes:
try:
return zlib.decompress(value)
except zlib.error:
return value


def _parse_scmap_payload(payload: bytes) -> RobotMap:
"""Parse inflated SCMap bytes into a generated protobuf message."""
parsed = RobotMap()
_parse_proto(payload, parsed, context="B01 SCMap")
return parsed
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.

I would say just inline/flatten this

Suggested change
def _parse_proto(blob: bytes, message: Message, *, context: str) -> None:
try:
message.ParseFromString(blob)
except DecodeError as err:
raise RoborockException(f"Failed to parse {context}") from err
def _decode_map_data_bytes(value: bytes) -> bytes:
try:
return zlib.decompress(value)
except zlib.error:
return value
def _parse_scmap_payload(payload: bytes) -> RobotMap:
"""Parse inflated SCMap bytes into a generated protobuf message."""
parsed = RobotMap()
_parse_proto(payload, parsed, context="B01 SCMap")
return parsed
def _decode_map_data_bytes(value: bytes) -> bytes:
try:
return zlib.decompress(value)
except zlib.error:
return value
def _parse_scmap_payload(payload: bytes) -> RobotMap:
"""Parse inflated SCMap bytes into a generated protobuf message."""
parsed = RobotMap()
try:
parsed.ParseFromString(payload)
except DecodeError as err:
raise RoborockException(f"Failed to parse B01 SCMap") from err
return parsed

return md5[8:24].encode()


def _encode_varint(value: int) -> bytes:
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.

Let's not encode fields manually, but use the same protobuf fields.

If you want to have some raw binary payloads somewhere to test the protobuf parsing that seems OK, but maybe in another test to verify the message parsing works.

try:
return zlib.decompress(value)
except zlib.error:
return value
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.

Is this expected to work? If so maybe worth adding a comment about the scenarios when the response is compressed vs uncompressed would be helpful.

I think this will be hard to debug otherwise, so maybe worth a log debug. So i'd say:
(1) If this should not happen, then raise an exception
(2) if it should happen sometimes, then it probably should log
(3) if you are unsure, ok to say that.

@arduano arduano force-pushed the leo/q7-map-content-followup branch 3 times, most recently from 7dc7aec to 8628087 Compare March 30, 2026 03:57
@arduano arduano force-pushed the leo/q7-map-content-followup branch from 8628087 to 0d43904 Compare March 30, 2026 04:14
@arduano
Copy link
Copy Markdown
Contributor Author

arduano commented Mar 30, 2026

I think that's all addressed? And yeah the decompression path was AI getting confused, tested on my live vacuum with it removed and it works

@arduano arduano requested a review from allenporter March 30, 2026 04:23
@arduano arduano force-pushed the leo/q7-map-content-followup branch from f942af2 to a891d3c Compare March 30, 2026 04:30
@allenporter
Copy link
Copy Markdown
Contributor

I think there are additional cleanups not addressed in some of the feedback, but we should probably still get this in and resolve the remaining cleanups.

@allenporter allenporter dismissed Lash-L’s stale review March 31, 2026 15:51

Comments are outdated now

@allenporter allenporter merged commit 607ef97 into Python-roborock:main Mar 31, 2026
7 checks passed
@allenporter
Copy link
Copy Markdown
Contributor

I don't really understand the current version pinning

E   google.protobuf.runtime_version.VersionError: Detected mismatched Protobuf Gencode/Runtime major versions when loading roborock/map/proto/b01_scmap.proto: gencode 6.31.1 runtime 5.29.3. 

You've pinned protobuf>=5,<7 but it doesn't work for this version range.

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.

5 participants