Skip to content

Commit 06c7845

Browse files
andigclaude
andcommitted
feat: live Q7 (B01) map updates from unsolicited map pushes
Q7 devices stream full SCMap frames (protocol 301) on their own during cleaning — no polling or request is needed. Verified against a live Q7 Series (roborock.vacuum.sc05): a short clean produced a pushed frame roughly every 10 seconds. - B01Q7Channel.subscribe_map_pushes() decodes unsolicited MAP_RESPONSE frames with the device map key. - MapContentTrait.update_from_push() re-parses pushed frames and notifies update listeners; malformed frames are dropped without clearing the cached map. - Q7PropertiesApi.start()/close() subscribe for the device lifetime, wired up in RoborockDevice.connect()/close() like V1 and Q10. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e8d5466 commit 06c7845

6 files changed

Lines changed: 132 additions & 2 deletions

File tree

roborock/devices/device.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,8 @@ async def connect(self) -> None:
202202
await self.v1_properties.start()
203203
elif self.b01_q10_properties is not None:
204204
await self.b01_q10_properties.start()
205+
elif self.b01_q7_properties is not None:
206+
await self.b01_q7_properties.start()
205207
except RoborockException:
206208
# Expected: start() can fail transiently. Unsubscribe before propagating
207209
# so the retry by connect_loop() gets a clean channel.
@@ -230,6 +232,8 @@ async def close(self) -> None:
230232
self.v1_properties.close()
231233
if self.b01_q10_properties is not None:
232234
await self.b01_q10_properties.close()
235+
if self.b01_q7_properties is not None:
236+
await self.b01_q7_properties.close()
233237
if self._unsub:
234238
self._unsub()
235239
self._unsub = None

roborock/devices/rpc/b01_q7_channel.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ async def send_map_command(
5353
"""Send a map command and get decoded bytes."""
5454
...
5555

56+
async def subscribe_map_pushes(self, callback: Callable[[bytes], None]) -> Callable[[], None]:
57+
"""Subscribe to unsolicited map pushes, invoking callback with decoded SCMap bytes."""
58+
...
59+
5660

5761
def _matches_map_response(response_message: RoborockMessage, *, version: bytes | None) -> bytes | None:
5862
"""Return raw map payload bytes for matching MAP_RESPONSE messages."""
@@ -208,6 +212,25 @@ async def send_map_command(
208212

209213
return decode_map_payload(raw_payload, map_key=self._map_key)
210214

215+
async def subscribe_map_pushes(self, callback: Callable[[bytes], None]) -> Callable[[], None]:
216+
"""Subscribe to unsolicited ``MAP_RESPONSE`` pushes.
217+
218+
The device streams full SCMap frames on its own during cleaning; the
219+
callback receives the decoded (inflated) SCMap bytes for each frame.
220+
"""
221+
222+
def on_message(message: RoborockMessage) -> None:
223+
if (raw_payload := _matches_map_response(message, version=B01_VERSION)) is None:
224+
return
225+
try:
226+
decoded = decode_map_payload(raw_payload, map_key=self._map_key)
227+
except RoborockException as ex:
228+
_LOGGER.debug("Failed to decode pushed B01 map payload: %s", ex)
229+
return
230+
callback(decoded)
231+
232+
return await self._mqtt_channel.subscribe(on_message)
233+
211234

212235
def create_b01_q7_channel(
213236
device: HomeDataDevice,

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
Potentially other devices may fall into this category in the future.
44
"""
55

6+
from collections.abc import Callable
67
from typing import Any
78

89
from roborock import B01Props
@@ -71,6 +72,17 @@ def __init__(
7172
self._map_rpc_channel,
7273
self.map,
7374
)
75+
self._unsub_map_pushes: Callable[[], None] | None = None
76+
77+
async def start(self) -> None:
78+
"""Start listening for unsolicited map pushes from the device."""
79+
self._unsub_map_pushes = await self._map_rpc_channel.subscribe_map_pushes(self.map_content.update_from_push)
80+
81+
async def close(self) -> None:
82+
"""Stop listening for unsolicited map pushes."""
83+
if self._unsub_map_pushes is not None:
84+
self._unsub_map_pushes()
85+
self._unsub_map_pushes = None
7486

7587
async def query_values(self, props: list[RoborockB01Props]) -> B01Props | None:
7688
"""Query the device for the values of the given Q7 properties."""

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

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,22 @@
99
"""
1010

1111
import asyncio
12+
import logging
1213
from dataclasses import dataclass
1314

1415
from vacuum_map_parser_base.map_data import MapData
1516

1617
from roborock.data import RoborockBase
1718
from roborock.devices.rpc.b01_q7_channel import Q7MapRpcChannel
1819
from roborock.devices.traits import Trait
20+
from roborock.devices.traits.common import TraitUpdateListener
1921
from roborock.exceptions import RoborockException
2022
from roborock.map.b01_map_parser import B01MapParser, B01MapParserConfig
2123
from roborock.roborock_typing import RoborockB01Q7Methods
2224

2325
from .map import MapTrait
2426

27+
_LOGGER = logging.getLogger(__name__)
2528
_TRUNCATE_LENGTH = 20
2629

2730

@@ -49,7 +52,7 @@ def __repr__(self) -> str:
4952
return f"MapContent(image_content={img!r}, map_data={self.map_data!r})"
5053

5154

52-
class MapContentTrait(MapContent, Trait):
55+
class MapContentTrait(MapContent, Trait, TraitUpdateListener):
5356
"""Trait for fetching parsed map content for Q7 devices."""
5457

5558
def __init__(
@@ -59,7 +62,8 @@ def __init__(
5962
*,
6063
map_parser_config: B01MapParserConfig | None = None,
6164
) -> None:
62-
super().__init__()
65+
MapContent.__init__(self)
66+
TraitUpdateListener.__init__(self, logger=_LOGGER)
6367
self._map_rpc_channel = map_rpc_channel
6468
self._map_trait = map_trait
6569
self._map_parser = B01MapParser(map_parser_config)
@@ -82,6 +86,23 @@ async def refresh(self) -> None:
8286
{"map_id": map_id},
8387
)
8488

89+
self._parse_and_store(raw_payload)
90+
91+
def update_from_push(self, raw_payload: bytes) -> None:
92+
"""Store an unsolicited SCMap frame pushed by the device during cleaning.
93+
94+
Pushed frames carry the live robot pose and cleaning path, so the
95+
rendered image stays current without polling.
96+
"""
97+
try:
98+
self._parse_and_store(raw_payload)
99+
except RoborockException as ex:
100+
_LOGGER.debug("Failed to parse pushed B01 map frame: %s", ex)
101+
return
102+
self._notify_update()
103+
104+
def _parse_and_store(self, raw_payload: bytes) -> None:
105+
"""Parse decoded SCMap bytes and update the cached fields."""
85106
try:
86107
parsed_data = self._map_parser.parse(raw_payload)
87108
except RoborockException:

tests/devices/traits/b01/q7/conftest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from collections.abc import Callable
12
from typing import Any
23

34
import pytest
@@ -14,6 +15,7 @@ def __init__(self) -> None:
1415
self.published_commands: list[tuple[Any, Any]] = []
1516
self.response_queue: list[Any] = []
1617
self.side_effect: Exception | None = None
18+
self.map_push_callback: Callable[[bytes], None] | None = None
1719

1820
async def send_command(self, command: Any, params: Any = None) -> Any:
1921
if self.side_effect:
@@ -29,6 +31,14 @@ async def send_map_command(self, command: Any, params: Any = None) -> bytes:
2931
return self.response_queue.pop(0)
3032
return b""
3133

34+
async def subscribe_map_pushes(self, callback: Callable[[bytes], None]) -> Callable[[], None]:
35+
self.map_push_callback = callback
36+
37+
def unsub() -> None:
38+
self.map_push_callback = None
39+
40+
return unsub
41+
3242

3343
@pytest.fixture(name="fake_channel")
3444
def fake_channel_fixture() -> FakeQ7Channel:

tests/devices/traits/b01/q7/test_map_content.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,63 @@ async def test_q7_map_content_refresh_errors_without_map_list(
8787

8888
with pytest.raises(RoborockException, match="Unable to determine current map ID"):
8989
await q7_api.map_content.refresh()
90+
91+
92+
async def test_q7_map_content_updates_from_push(
93+
q7_api: Q7PropertiesApi,
94+
fake_channel: FakeQ7Channel,
95+
):
96+
"""Unsolicited map pushes update the cached map and notify listeners."""
97+
await q7_api.start()
98+
assert fake_channel.map_push_callback is not None
99+
100+
updates: list[bool] = []
101+
q7_api.map_content.add_update_listener(lambda: updates.append(True))
102+
103+
dummy_map_data = MapData()
104+
parsed_map_data = ParsedMapData(
105+
image_content=b"pngbytes",
106+
map_data=dummy_map_data,
107+
)
108+
with patch(
109+
"roborock.devices.traits.b01.q7.map_content.B01MapParser.parse",
110+
return_value=parsed_map_data,
111+
):
112+
fake_channel.map_push_callback(b"pushed-payload")
113+
114+
assert q7_api.map_content.image_content == b"pngbytes"
115+
assert q7_api.map_content.raw_api_response == b"pushed-payload"
116+
assert updates == [True]
117+
118+
await q7_api.close()
119+
assert fake_channel.map_push_callback is None
120+
121+
122+
async def test_q7_map_content_push_parse_failure_keeps_previous_map(
123+
q7_api: Q7PropertiesApi,
124+
fake_channel: FakeQ7Channel,
125+
):
126+
"""A malformed pushed frame is dropped without clearing cached content."""
127+
await q7_api.start()
128+
assert fake_channel.map_push_callback is not None
129+
130+
dummy_map_data = MapData()
131+
parsed_map_data = ParsedMapData(
132+
image_content=b"pngbytes",
133+
map_data=dummy_map_data,
134+
)
135+
with patch(
136+
"roborock.devices.traits.b01.q7.map_content.B01MapParser.parse",
137+
return_value=parsed_map_data,
138+
):
139+
fake_channel.map_push_callback(b"good-payload")
140+
141+
updates: list[bool] = []
142+
q7_api.map_content.add_update_listener(lambda: updates.append(True))
143+
144+
fake_channel.map_push_callback(b"not a map")
145+
146+
assert q7_api.map_content.image_content == b"pngbytes"
147+
assert q7_api.map_content.raw_api_response == b"good-payload"
148+
assert updates == []
149+
await q7_api.close()

0 commit comments

Comments
 (0)