Skip to content

Commit 607ef97

Browse files
authored
q7: add map_content trait for B01 current map (#785)
* feat(q7): add b01 map_content support * refactor: make q7 scmap parsing declarative * refactor: trim q7 map parser scope * refactor: restore declarative q7 scmap fields * feat: define checked-in proto for q7 scmap * fix: pass q7 scmap lint checks * fix: avoid extra mypy surface from protobuf stubs * fix: scope mypy protobuf ignore to generated module * fix: add protobuf stubs to mypy hook * refactor(q7): address maintainer review follow-ups * docs(q7): refresh protobuf regeneration note * fix(ci): stop passing duplicate ruff exclude flag * refactor(q7): use generated protobuf message types * refactor(q7): remove intermediate SCMap mapping layer * test(q7): simplify map parser follow-up fixtures * refactor(q7): use shared ECB helpers for map decode
1 parent caf4dbb commit 607ef97

13 files changed

Lines changed: 671 additions & 13 deletions

File tree

.pre-commit-config.yaml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# See https://pre-commit.com for more information
22
# See https://pre-commit.com/hooks.html for more hooks
3-
exclude: "CHANGELOG.md"
3+
exclude: >
4+
(?x)^(
5+
CHANGELOG\.md|
6+
roborock/map/proto/.*_pb2\.py
7+
)$
48
default_stages: [ pre-commit ]
59

610
repos:
@@ -42,7 +46,7 @@ repos:
4246
hooks:
4347
- id: mypy
4448
exclude: cli.py
45-
additional_dependencies: [ "types-paho-mqtt" ]
49+
additional_dependencies: [ "types-paho-mqtt", "types-protobuf" ]
4650
- repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook
4751
rev: v9.23.0
4852
hooks:

pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ dependencies = [
2525
"pycryptodomex~=3.18 ; sys_platform == 'darwin'",
2626
"paho-mqtt>=1.6.1,<3.0.0",
2727
"construct>=2.10.57,<3",
28+
"protobuf>=5,<7",
2829
"vacuum-map-parser-roborock",
2930
"pyrate-limiter>=4.0.0,<5",
3031
"aiomqtt>=2.5.0,<3",
@@ -97,9 +98,15 @@ major_tags= ["refactor"]
9798
lint.ignore = ["F403", "E741"]
9899
lint.select=["E", "F", "UP", "I"]
99100
line-length = 120
101+
extend-exclude = ["roborock/map/proto/*_pb2.py"]
100102

101103
[tool.ruff.lint.per-file-ignores]
102104
"*/__init__.py" = ["F401"]
105+
"roborock/map/proto/*_pb2.py" = ["E501", "I001", "UP009"]
106+
107+
[[tool.mypy.overrides]]
108+
module = ["roborock.map.proto.*"]
109+
ignore_errors = true
103110

104111
[tool.pytest.ini_options]
105112
asyncio_mode = "auto"

roborock/devices/device_manager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDat
251251
trait = b01.q10.create(channel)
252252
elif "sc" in model_part:
253253
# Q7 devices start with 'sc' in their model naming.
254-
trait = b01.q7.create(channel)
254+
trait = b01.q7.create(product, device, channel)
255255
else:
256256
raise UnsupportedDeviceError(f"Device {device.name} has unsupported B01 model: {product.model}")
257257
case _:

roborock/devices/traits/b01/q7/__init__.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
"""Traits for Q7 B01 devices.
2-
Potentially other devices may fall into this category in the future."""
2+
3+
Potentially other devices may fall into this category in the future.
4+
"""
5+
6+
from __future__ import annotations
37

48
from typing import Any
59

610
from roborock import B01Props
7-
from roborock.data import Q7MapList, Q7MapListEntry
11+
from roborock.data import HomeDataDevice, HomeDataProduct, Q7MapList, Q7MapListEntry
812
from roborock.data.b01_q7.b01_q7_code_mappings import (
913
CleanPathPreferenceMapping,
1014
CleanRepeatMapping,
@@ -23,30 +27,46 @@
2327

2428
from .clean_summary import CleanSummaryTrait
2529
from .map import MapTrait
30+
from .map_content import MapContentTrait
2631

2732
__all__ = [
2833
"Q7PropertiesApi",
2934
"CleanSummaryTrait",
3035
"MapTrait",
36+
"MapContentTrait",
3137
"Q7MapList",
3238
"Q7MapListEntry",
3339
]
3440

3541

3642
class Q7PropertiesApi(Trait):
37-
"""API for interacting with B01 devices."""
43+
"""API for interacting with B01 Q7 devices."""
3844

3945
clean_summary: CleanSummaryTrait
4046
"""Trait for clean records / clean summary (Q7 `service.get_record_list`)."""
4147

4248
map: MapTrait
4349
"""Trait for map list metadata + raw map payload retrieval."""
4450

45-
def __init__(self, channel: MqttChannel) -> None:
46-
"""Initialize the B01Props API."""
51+
map_content: MapContentTrait
52+
"""Trait for fetching parsed current map content."""
53+
54+
def __init__(self, channel: MqttChannel, *, device: HomeDataDevice, product: HomeDataProduct) -> None:
55+
"""Initialize the Q7 API."""
4756
self._channel = channel
57+
self._device = device
58+
self._product = product
59+
60+
if not device.sn or not product.model:
61+
raise ValueError("B01 Q7 map content requires device serial number and product model metadata")
62+
4863
self.clean_summary = CleanSummaryTrait(channel)
4964
self.map = MapTrait(channel)
65+
self.map_content = MapContentTrait(
66+
self.map,
67+
serial=device.sn,
68+
model=product.model,
69+
)
5070

5171
async def query_values(self, props: list[RoborockB01Props]) -> B01Props | None:
5272
"""Query the device for the values of the given Q7 properties."""
@@ -151,6 +171,6 @@ async def send(self, command: CommandType, params: ParamsType) -> Any:
151171
)
152172

153173

154-
def create(channel: MqttChannel) -> Q7PropertiesApi:
155-
"""Create traits for B01 devices."""
156-
return Q7PropertiesApi(channel)
174+
def create(product: HomeDataProduct, device: HomeDataDevice, channel: MqttChannel) -> Q7PropertiesApi:
175+
"""Create traits for B01 Q7 devices."""
176+
return Q7PropertiesApi(channel, device=device, product=product)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Trait for fetching parsed map content from B01/Q7 devices.
2+
3+
This intentionally mirrors the v1 `MapContentTrait` contract:
4+
- `refresh()` performs I/O and populates cached fields
5+
- `parse_map_content()` reparses cached raw bytes without I/O
6+
- fields `image_content`, `map_data`, and `raw_api_response` are then readable
7+
8+
For B01/Q7 devices, the underlying raw map payload is retrieved via `MapTrait`.
9+
"""
10+
11+
from dataclasses import dataclass
12+
13+
from vacuum_map_parser_base.map_data import MapData
14+
15+
from roborock.data import RoborockBase
16+
from roborock.devices.traits import Trait
17+
from roborock.exceptions import RoborockException
18+
from roborock.map.b01_map_parser import B01MapParser, B01MapParserConfig
19+
20+
from .map import MapTrait
21+
22+
_TRUNCATE_LENGTH = 20
23+
24+
25+
@dataclass
26+
class MapContent(RoborockBase):
27+
"""Dataclass representing map content."""
28+
29+
image_content: bytes | None = None
30+
"""The rendered image of the map in PNG format."""
31+
32+
map_data: MapData | None = None
33+
"""Parsed map data (metadata for points on the map)."""
34+
35+
raw_api_response: bytes | None = None
36+
"""Raw bytes of the map payload from the device.
37+
38+
This should be treated as an opaque blob used only internally by this
39+
library to re-parse the map data when needed.
40+
"""
41+
42+
def __repr__(self) -> str:
43+
img = self.image_content
44+
if img and len(img) > _TRUNCATE_LENGTH:
45+
img = img[: _TRUNCATE_LENGTH - 3] + b"..."
46+
return f"MapContent(image_content={img!r}, map_data={self.map_data!r})"
47+
48+
49+
class MapContentTrait(MapContent, Trait):
50+
"""Trait for fetching parsed map content for Q7 devices."""
51+
52+
def __init__(
53+
self,
54+
map_trait: MapTrait,
55+
*,
56+
serial: str,
57+
model: str,
58+
map_parser_config: B01MapParserConfig | None = None,
59+
) -> None:
60+
super().__init__()
61+
self._map_trait = map_trait
62+
self._serial = serial
63+
self._model = model
64+
self._map_parser = B01MapParser(map_parser_config)
65+
66+
async def refresh(self) -> None:
67+
"""Fetch, decode, and parse the current map payload."""
68+
raw_payload = await self._map_trait.get_current_map_payload()
69+
parsed = self.parse_map_content(raw_payload)
70+
self.image_content = parsed.image_content
71+
self.map_data = parsed.map_data
72+
self.raw_api_response = parsed.raw_api_response
73+
74+
def parse_map_content(self, response: bytes) -> MapContent:
75+
"""Parse map content from raw bytes.
76+
77+
This mirrors the v1 trait behavior so cached map payload bytes can be
78+
reparsed without going back to the device.
79+
"""
80+
try:
81+
parsed_data = self._map_parser.parse(
82+
response,
83+
serial=self._serial,
84+
model=self._model,
85+
)
86+
except RoborockException:
87+
raise
88+
except Exception as ex:
89+
raise RoborockException("Failed to parse B01 map data") from ex
90+
91+
if parsed_data.image_content is None:
92+
raise RoborockException("Failed to render B01 map image")
93+
94+
return MapContent(
95+
image_content=parsed_data.image_content,
96+
map_data=parsed_data.map_data,
97+
raw_api_response=response,
98+
)

0 commit comments

Comments
 (0)