q7: add map_content trait for B01 current map#785
q7: add map_content trait for B01 current map#785allenporter merged 19 commits intoPython-roborock:mainfrom
Conversation
There was a problem hiding this comment.
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
B01MapParserto decode MAP_RESPONSE payloads, parse inflated SCMap fields, and render a PNG +MapData. - Adds
MapContentTraitfor B01/Q7 to refresh/fetch current map payload viaMapTraitand expose cachedimage_content,map_data, and raw bytes. - Wires Q7 trait creation through
device_managerwith 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.
a81db61 to
67fc042
Compare
67fc042 to
487778a
Compare
|
Setting this to draft until I push protobuf stuff |
8ef5515 to
2c839e0
Compare
2c839e0 to
2d73fd1
Compare
allenporter
left a comment
There was a problem hiding this comment.
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?
|
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. |
6b3ec67 to
bedf379
Compare
| For B01/Q7 devices, the underlying raw map payload is retrieved via `MapTrait`. | ||
| """ | ||
|
|
||
| from __future__ import annotations |
There was a problem hiding this comment.
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 | |||
There was a problem hiding this comment.
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.
2dfd139 to
5d19def
Compare
5d19def to
99a63e6
Compare
|
How does it look now? |
allenporter
left a comment
There was a problem hiding this comment.
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...
roborock/map/b01_map_parser.py
Outdated
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class _ScPoint: |
There was a problem hiding this comment.
are these still needing to move to generated messages?
I'm following this work so closely 😔 |
|
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 |
allenporter
left a comment
There was a problem hiding this comment.
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.
roborock/map/b01_map_parser.py
Outdated
|
|
||
| from .map_parser import ParsedMapData | ||
|
|
||
| _B64_CHARS = set(b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=") |
There was a problem hiding this comment.
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?
roborock/map/b01_map_parser.py
Outdated
| 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) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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:
Though i think its fine to level the protocol buffer parts here.
| 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 |
There was a problem hiding this comment.
I would say just inline/flatten this
| 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 |
tests/map/test_b01_map_parser.py
Outdated
| return md5[8:24].encode() | ||
|
|
||
|
|
||
| def _encode_varint(value: int) -> bytes: |
There was a problem hiding this comment.
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.
roborock/map/b01_map_parser.py
Outdated
| try: | ||
| return zlib.decompress(value) | ||
| except zlib.error: | ||
| return value |
There was a problem hiding this comment.
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.
7dc7aec to
8628087
Compare
8628087 to
0d43904
Compare
|
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 |
f942af2 to
a891d3c
Compare
|
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. |
|
I don't really understand the current version pinning You've pinned |
Summary
Add Q7/B01
map_contentsupport on top of the already-merged raw map transport work.This PR:
map_contenttrait with the same basic refresh/cached-fields pattern used by v1device_managerwith access to the device/product metadata needed for map decodingMAP_RESPONSEpayloadmap_data.additional_parameters["room_names"]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:
maptraitmap_contenttraitrefresh()performs I/O and populates cached fieldsValidation
pytest -q→398 passed, 1 warningruff check .→ cleantests/map/test_b01_map_parser.pytests/devices/traits/b01/q7/test_map_content.py7 passedroborock.vacuum.sc05174573558410..19/room1..room10Notes
main; this branch does not add new parser-specific mypy failures, but I did not include unrelated mypy cleanups in this PR.