Skip to content

Commit 0fee055

Browse files
refactor: split Q10 map list and content
1 parent c441503 commit 0fee055

5 files changed

Lines changed: 248 additions & 111 deletions

File tree

roborock/cli.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -608,14 +608,11 @@ async def _await_q10_map_push(
608608
timeout: float = _Q10_MAP_PUSH_TIMEOUT,
609609
allow_cached_on_timeout: bool = False,
610610
) -> bool:
611-
"""Nudge a Q10 to push its map/trace and wait for a fresh update.
612-
613-
The Q10 map response remains asynchronous: ``refresh`` starts a
614-
``dpMultiMap`` list/get exchange, after which the device publishes a
615-
``MAP_RESPONSE`` that its subscribe loop feeds into the map trait. Here we
616-
register a packet-specific listener, send the request, and wait for a newly
617-
pushed update to satisfy ``predicate``. Returns whether it did within
618-
``timeout``.
611+
"""Request Q10 map content and wait for a fresh map or trace packet.
612+
613+
A Q10 needs a saved-map ID before it can request content. The map list and
614+
content have independent refresh schedules, so the list is requested only
615+
when no ID is stored. The content then arrives as a later ``MAP_RESPONSE``.
619616
"""
620617
loop = asyncio.get_running_loop()
621618
updated: asyncio.Future[None] = loop.create_future()
@@ -626,8 +623,23 @@ def on_update() -> None:
626623

627624
unsub = add_source_listener(on_update)
628625
try:
629-
await properties.map.refresh()
630-
await asyncio.wait_for(updated, timeout=timeout)
626+
async with asyncio.timeout(timeout):
627+
if properties.maps.current_map_id is None:
628+
map_list_updated: asyncio.Future[None] = loop.create_future()
629+
630+
def on_map_list_update() -> None:
631+
if properties.maps.current_map_id is not None and not map_list_updated.done():
632+
map_list_updated.set_result(None)
633+
634+
unsub_maps = properties.maps.add_update_listener(on_map_list_update)
635+
try:
636+
await properties.maps.refresh()
637+
if properties.maps.current_map_id is None:
638+
await map_list_updated
639+
finally:
640+
unsub_maps()
641+
await properties.map.refresh()
642+
await updated
631643
return True
632644
except TimeoutError:
633645
return allow_cached_on_timeout and predicate()

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

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from .do_not_disturb import DoNotDisturbTrait
1818
from .dust_collection import DustCollectionTrait
1919
from .map import MapContentTrait, MapDpsTrait
20+
from .maps import MapsTrait
2021
from .network_info import NetworkInfoTrait
2122
from .remote import RemoteTrait
2223
from .status import StatusTrait
@@ -32,6 +33,7 @@
3233
"DoNotDisturbTrait",
3334
"DustCollectionTrait",
3435
"MapContentTrait",
36+
"MapsTrait",
3537
"NetworkInfoTrait",
3638
"SoundVolumeTrait",
3739
"StatusTrait",
@@ -79,6 +81,9 @@ class Q10PropertiesApi(Trait):
7981
map: MapContentTrait
8082
"""Composed map image plus caller-facing map and trace data."""
8183

84+
maps: MapsTrait
85+
"""Saved-map list metadata."""
86+
8287
_map_dps: MapDpsTrait
8388
"""Private source of restricted zones and virtual walls received through DPS."""
8489

@@ -100,7 +105,8 @@ def __init__(self, channel: B01Q10Channel) -> None:
100105
self.network_info = NetworkInfoTrait()
101106
self.consumable = ConsumableTrait()
102107
self._map_dps = MapDpsTrait()
103-
self.map = MapContentTrait(self._map_dps, self.command)
108+
self.maps = MapsTrait(self.command)
109+
self.map = MapContentTrait(self._map_dps, self.maps, self.command)
104110
self.clean_history = CleanHistoryTrait(self.command)
105111
# Read-model traits updated from the device's DPS push stream.
106112
self._updatable_traits = [
@@ -113,6 +119,7 @@ def __init__(self, channel: B01Q10Channel) -> None:
113119
self.consumable,
114120
self.clean_history,
115121
self._map_dps,
122+
self.maps,
116123
]
117124
self._subscribe_task: asyncio.Task[None] | None = None
118125

@@ -133,20 +140,19 @@ async def close(self) -> None:
133140
async def refresh(self) -> None:
134141
"""Refresh all traits."""
135142
# Sending REQUEST_DPS causes the device to publish its ordinary status
136-
# values. Map refreshes have their own cadence through ``map.refresh()``.
143+
# values. Map-list and map-content refreshes have separate schedules.
137144
await self.command.send(B01_Q10_DP.REQUEST_DPS, params={})
138145

139146
async def _subscribe_loop(self) -> None:
140147
"""Persistent loop dispatching decoded messages to the read-model traits."""
141148
async for message in self._channel.subscribe_stream():
142-
await self._handle_message(message)
149+
self._handle_message(message)
143150

144-
async def _handle_message(self, message: Q10Message) -> None:
151+
def _handle_message(self, message: Q10Message) -> None:
145152
"""Route a single decoded message to the trait responsible for it.
146153
147154
Map and trace packets arrive as protocol-301 ``MAP_RESPONSE`` pushes.
148-
Map-list DPS responses are handed to the map trait; other DPS updates
149-
feed the read-model traits.
155+
Map-list DPS responses and other DPS updates feed the read-model traits.
150156
"""
151157
if isinstance(message, Q10MapPacket):
152158
self.map.update_from_map_packet(message)
@@ -158,7 +164,6 @@ async def _handle_message(self, message: Q10Message) -> None:
158164
# only updates the fields that it is responsible for.
159165
for trait in self._updatable_traits:
160166
trait.update_from_dps(message.dps)
161-
await self.map.update_from_dps(message.dps)
162167

163168

164169
def create(channel: B01Q10Channel) -> Q10PropertiesApi:

roborock/devices/traits/b01/q10/map.py

Lines changed: 23 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,10 @@
66
* trace packets are decoded from trace-protocol responses;
77
* restricted zones and virtual walls arrive as ordinary DPS values.
88
9-
``MapDpsTrait`` owns the low-level DPS read model. ``MapContentTrait`` depends
10-
on it and combines that state with the latest map/trace packets through the pure
11-
functions in :mod:`roborock.map.b01_q10_render`. The high-level trait keeps only
12-
the latest value from each source and one replace-whole rendered image;
13-
calibration, path placement and overlay placement remain inside the renderer.
9+
``MapDpsTrait`` owns the low-level DPS read model. ``MapContentTrait`` uses a
10+
stored ID from ``MapsTrait`` only when it requests content. It combines map,
11+
trace, and overlay state through the pure functions in
12+
:mod:`roborock.map.b01_q10_render`. Map-list updates do not refresh content.
1413
"""
1514

1615
import logging
@@ -21,7 +20,6 @@
2120
from roborock.callbacks import CallbackList
2221
from roborock.data import RoborockBase
2322
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
24-
from roborock.data.b01_q10.b01_q10_containers import dpMultiMap
2523
from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener
2624
from roborock.exceptions import RoborockException
2725
from roborock.map.b01_q10_map_parser import (
@@ -36,6 +34,7 @@
3634

3735
from .command import CommandTrait
3836
from .common import UpdatableTrait
37+
from .maps import MapsTrait
3938

4039
_LOGGER = logging.getLogger(__name__)
4140

@@ -74,33 +73,27 @@ def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
7473
self._notify_update()
7574

7675

77-
@dataclass
78-
class MapListDps(RoborockBase):
79-
"""Typed ``dpMultiMap`` state delivered through the Q10 DPS stream."""
80-
81-
multi_map: dpMultiMap | None = field(default=None, metadata={"dps": B01_Q10_DP.MULTI_MAP})
82-
83-
84-
class MapContentTrait(MapListDps, TraitUpdateListener):
76+
class MapContentTrait(TraitUpdateListener):
8577
"""High-level composed Q10 map view.
8678
8779
The latest map and trace packets are combined with the injected
88-
:class:`MapDpsTrait` whenever any of those three sources changes.
80+
:class:`MapDpsTrait` whenever any of those three sources changes. The
81+
:class:`MapsTrait` supplies a stored ID only when this trait requests
82+
content.
8983
"""
9084

91-
_CONVERTER = DpsDataConverter.from_dataclass(MapListDps)
92-
9385
def __init__(
9486
self,
9587
map_dps: MapDpsTrait,
96-
command: CommandTrait | None = None,
88+
maps: MapsTrait,
89+
command: CommandTrait,
9790
*,
9891
map_parser_config: B01Q10MapParserConfig | None = None,
9992
) -> None:
100-
MapListDps.__init__(self)
10193
TraitUpdateListener.__init__(self, logger=_LOGGER)
10294
self._config = map_parser_config or B01Q10MapParserConfig()
10395
self._map_dps = map_dps
96+
self._maps = maps
10497
self._command = command
10598
self._map_packet: Q10MapPacket | None = None
10699
self._trace_packet: Q10TracePacket | None = None
@@ -110,37 +103,21 @@ def __init__(
110103
self._map_dps.add_update_listener(self._map_dps_updated)
111104

112105
async def refresh(self) -> None:
113-
"""Request the current saved map independently of general status."""
114-
if self._command is None:
115-
raise ValueError("Trait is read-only; no command channel was provided")
106+
"""Request content for the first map in the latest saved-map list."""
107+
if (map_id := self._maps.current_map_id) is None:
108+
raise RoborockException("Cannot request Q10 map content before the map list is available")
109+
# Map lists and map content can change at different times. Reuse the
110+
# stored ID so a content refresh does not also refresh the list.
116111
await self._command.send(
117112
B01_Q10_DP.COMMON,
118-
{str(B01_Q10_DP.MULTI_MAP.code): {"op": "list"}},
113+
{
114+
str(B01_Q10_DP.MULTI_MAP.code): {
115+
"op": "get",
116+
"id": map_id,
117+
}
118+
},
119119
)
120120

121-
async def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
122-
"""Request map content when a typed ``dpMultiMap`` list response arrives."""
123-
if not self._CONVERTER.update_from_dps(self, decoded_dps):
124-
return
125-
if self._command is None or self.multi_map is None or self.multi_map.op != "list" or self.multi_map.result != 1:
126-
return
127-
if (map_id := self.multi_map.current_map_id) is None:
128-
_LOGGER.debug("Q10 map list response did not contain a map ID")
129-
return
130-
try:
131-
await self._command.send(
132-
B01_Q10_DP.COMMON,
133-
{
134-
str(B01_Q10_DP.MULTI_MAP.code): {
135-
"op": "get",
136-
"id": map_id,
137-
}
138-
},
139-
)
140-
except RoborockException as ex:
141-
# A failed follow-up must not kill the persistent subscribe loop.
142-
_LOGGER.debug("Failed to request Q10 map content: %s", ex)
143-
144121
@property
145122
def image_content(self) -> bytes | None:
146123
"""The composed map PNG, if the latest map rendered successfully."""
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Trait for Q10 saved-map list data."""
2+
3+
import logging
4+
from dataclasses import dataclass, field
5+
from typing import Any
6+
7+
from roborock.data import RoborockBase
8+
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
9+
from roborock.data.b01_q10.b01_q10_containers import dpMultiMap
10+
from roborock.devices.traits.common import DpsDataConverter
11+
12+
from .command import CommandTrait
13+
from .common import UpdatableTrait
14+
15+
_LOGGER = logging.getLogger(__name__)
16+
17+
18+
@dataclass
19+
class Maps(RoborockBase):
20+
"""Saved-map list data from the Q10 DPS stream."""
21+
22+
multi_map: dpMultiMap | None = field(default=None, metadata={"dps": B01_Q10_DP.MULTI_MAP})
23+
24+
@property
25+
def current_map_id(self) -> str | None:
26+
"""Return the first saved-map ID for a content request, if available."""
27+
if self.multi_map is None or self.multi_map.op != "list" or self.multi_map.result != 1:
28+
return None
29+
return self.multi_map.current_map_id
30+
31+
32+
class MapsTrait(Maps, UpdatableTrait):
33+
"""Request and store the Q10 saved-map list."""
34+
35+
_CONVERTER = DpsDataConverter.from_dataclass(Maps)
36+
_command: CommandTrait
37+
38+
def __init__(self, command: CommandTrait) -> None:
39+
"""Initialize the saved-map list trait."""
40+
Maps.__init__(self)
41+
UpdatableTrait.__init__(self, command, _LOGGER)
42+
self._command = command
43+
44+
async def refresh(self) -> None:
45+
"""Request a new saved-map list from the device."""
46+
await self._command.send(
47+
B01_Q10_DP.COMMON,
48+
{str(B01_Q10_DP.MULTI_MAP.code): {"op": "list"}},
49+
)
50+
51+
def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
52+
"""Store a successful saved-map list response."""
53+
response = decoded_dps.get(B01_Q10_DP.MULTI_MAP)
54+
# DP 61 also carries map-content acknowledgements. Ignore them so they
55+
# cannot replace a usable map list with an unrelated response.
56+
if not isinstance(response, dict) or response.get("op") != "list" or response.get("result") != 1:
57+
return
58+
super().update_from_dps(decoded_dps)

0 commit comments

Comments
 (0)