Skip to content

Commit c441503

Browse files
refactor: move Q10 refresh into map trait
1 parent a4fbe97 commit c441503

6 files changed

Lines changed: 104 additions & 197 deletions

File tree

roborock/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -626,7 +626,7 @@ def on_update() -> None:
626626

627627
unsub = add_source_listener(on_update)
628628
try:
629-
await properties.refresh()
629+
await properties.map.refresh()
630630
await asyncio.wait_for(updated, timeout=timeout)
631631
return True
632632
except TimeoutError:

roborock/data/b01_q10/b01_q10_containers.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,32 @@ def start_datetime(self) -> datetime.datetime | None:
8383
return None
8484

8585

86+
@dataclass
87+
class Q10MapInfo(RoborockBase):
88+
"""A saved map reported by ``dpMultiMap``.
89+
90+
Q10 firmware represents the map identifier as a string on the wire. The
91+
value is sent back unchanged in a subsequent ``{"op": "get"}`` request.
92+
"""
93+
94+
id: str
95+
name: str | None = None
96+
timestamp: int | None = None
97+
98+
8699
@dataclass
87100
class dpMultiMap(RoborockBase):
101+
"""Response envelope for the Q10 ``dpMultiMap`` data point."""
102+
88103
op: str
89104
result: int
90-
data: list
105+
data: list[Q10MapInfo] = field(default_factory=list)
106+
107+
@property
108+
def current_map_id(self) -> str | None:
109+
"""Return the first saved-map identifier, if one was reported."""
110+
first = next((map_info for map_info in self.data if isinstance(map_info, Q10MapInfo) and map_info.id), None)
111+
return first.id if first else None
91112

92113

93114
@dataclass

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

Lines changed: 6 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,10 @@
22

33
import asyncio
44
import logging
5-
from typing import Any
65

76
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
87
from roborock.devices.rpc.b01_q10_channel import B01Q10Channel
98
from roborock.devices.traits import Trait
10-
from roborock.exceptions import RoborockException
119
from roborock.map.b01_q10_map_parser import Q10MapPacket, Q10TracePacket
1210
from roborock.protocols.b01_q10_protocol import Q10DpsUpdate, Q10Message
1311

@@ -40,25 +38,6 @@
4038
]
4139

4240
_LOGGER = logging.getLogger(__name__)
43-
_MAP_LIST_REQUEST_TIMEOUT = 30.0
44-
45-
46-
def _map_id_from_list_response(response: Any) -> int | str | None:
47-
"""Return the first usable map ID from a ``dpMultiMap`` list response."""
48-
if not isinstance(response, dict) or response.get("op") != "list":
49-
return None
50-
data = response.get("data")
51-
if not isinstance(data, list):
52-
return None
53-
for map_info in data:
54-
if not isinstance(map_info, dict):
55-
continue
56-
map_id = map_info.get("id")
57-
if isinstance(map_id, int) and not isinstance(map_id, bool):
58-
return map_id
59-
if isinstance(map_id, str) and map_id:
60-
return map_id
61-
return None
6241

6342

6443
class Q10PropertiesApi(Trait):
@@ -121,7 +100,7 @@ def __init__(self, channel: B01Q10Channel) -> None:
121100
self.network_info = NetworkInfoTrait()
122101
self.consumable = ConsumableTrait()
123102
self._map_dps = MapDpsTrait()
124-
self.map = MapContentTrait(self._map_dps)
103+
self.map = MapContentTrait(self._map_dps, self.command)
125104
self.clean_history = CleanHistoryTrait(self.command)
126105
# Read-model traits updated from the device's DPS push stream.
127106
self._updatable_traits = [
@@ -136,9 +115,6 @@ def __init__(self, channel: B01Q10Channel) -> None:
136115
self._map_dps,
137116
]
138117
self._subscribe_task: asyncio.Task[None] | None = None
139-
self._map_request_lock = asyncio.Lock()
140-
self._map_list_request_token: object | None = None
141-
self._map_list_requested_at: float | None = None
142118

143119
async def start(self) -> None:
144120
"""Start any necessary subscriptions for the trait."""
@@ -156,42 +132,9 @@ async def close(self) -> None:
156132

157133
async def refresh(self) -> None:
158134
"""Refresh all traits."""
159-
# Status and map retrieval use separate Q10 requests. A bare REQUEST_DPS
160-
# reliably refreshes status but does not reliably make every firmware
161-
# publish its map.
135+
# Sending REQUEST_DPS causes the device to publish its ordinary status
136+
# values. Map refreshes have their own cadence through ``map.refresh()``.
162137
await self.command.send(B01_Q10_DP.REQUEST_DPS, params={})
163-
await self.request_map()
164-
165-
async def request_map(self) -> None:
166-
"""Request the current saved map through the Q10 multi-map protocol.
167-
168-
The list response arrives asynchronously on the subscribe stream.
169-
``_handle_message`` extracts its first map ID and follows up with the
170-
matching ``get`` command; the resulting protocol-301 map packet is
171-
routed to :attr:`map`.
172-
"""
173-
async with self._map_request_lock:
174-
now = asyncio.get_running_loop().time()
175-
if (
176-
self._map_list_request_token is not None
177-
and self._map_list_requested_at is not None
178-
and now - self._map_list_requested_at < _MAP_LIST_REQUEST_TIMEOUT
179-
):
180-
return
181-
token = object()
182-
self._map_list_request_token = token
183-
self._map_list_requested_at = now
184-
sent = False
185-
try:
186-
await self.command.send(
187-
B01_Q10_DP.COMMON,
188-
{str(B01_Q10_DP.MULTI_MAP.code): {"op": "list"}},
189-
)
190-
sent = True
191-
finally:
192-
if not sent and self._map_list_request_token is token:
193-
self._map_list_request_token = None
194-
self._map_list_requested_at = None
195138

196139
async def _subscribe_loop(self) -> None:
197140
"""Persistent loop dispatching decoded messages to the read-model traits."""
@@ -202,9 +145,8 @@ async def _handle_message(self, message: Q10Message) -> None:
202145
"""Route a single decoded message to the trait responsible for it.
203146
204147
Map and trace packets arrive as protocol-301 ``MAP_RESPONSE`` pushes.
205-
A ``dpMultiMap`` list response completes the asynchronous request flow
206-
started by :meth:`request_map`; other DPS updates feed the read-model
207-
traits.
148+
Map-list DPS responses are handed to the map trait; other DPS updates
149+
feed the read-model traits.
208150
"""
209151
if isinstance(message, Q10MapPacket):
210152
self.map.update_from_map_packet(message)
@@ -216,39 +158,7 @@ async def _handle_message(self, message: Q10Message) -> None:
216158
# only updates the fields that it is responsible for.
217159
for trait in self._updatable_traits:
218160
trait.update_from_dps(message.dps)
219-
await self._request_map_from_list_response(message.dps)
220-
221-
async def _request_map_from_list_response(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
222-
"""Request map content after receiving our pending map-list response."""
223-
response = decoded_dps.get(B01_Q10_DP.MULTI_MAP)
224-
if self._map_list_request_token is None or not isinstance(response, dict) or response.get("op") != "list":
225-
return
226-
227-
token = self._map_list_request_token
228-
if (map_id := _map_id_from_list_response(response)) is None:
229-
if self._map_list_request_token is token:
230-
self._map_list_request_token = None
231-
self._map_list_requested_at = None
232-
_LOGGER.debug("Q10 map list response did not contain a usable map ID")
233-
return
234-
235-
try:
236-
await self.command.send(
237-
B01_Q10_DP.COMMON,
238-
{
239-
str(B01_Q10_DP.MULTI_MAP.code): {
240-
"op": "get",
241-
"id": map_id,
242-
}
243-
},
244-
)
245-
except RoborockException as ex:
246-
# A failed follow-up must not kill the persistent subscribe loop.
247-
_LOGGER.debug("Failed to request Q10 map content: %s", ex)
248-
finally:
249-
if self._map_list_request_token is token:
250-
self._map_list_request_token = None
251-
self._map_list_requested_at = None
161+
await self.map.update_from_dps(message.dps)
252162

253163

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

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

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from roborock.callbacks import CallbackList
2222
from roborock.data import RoborockBase
2323
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
24+
from roborock.data.b01_q10.b01_q10_containers import dpMultiMap
2425
from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener
2526
from roborock.exceptions import RoborockException
2627
from roborock.map.b01_q10_map_parser import (
@@ -33,6 +34,7 @@
3334
from roborock.map.b01_q10_overlays import parse_virtual_wall_blob, parse_zone_blob
3435
from roborock.map.b01_q10_render import Q10MapOverlays, render_q10_map
3536

37+
from .command import CommandTrait
3638
from .common import UpdatableTrait
3739

3840
_LOGGER = logging.getLogger(__name__)
@@ -72,29 +74,73 @@ def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
7274
self._notify_update()
7375

7476

75-
class MapContentTrait(TraitUpdateListener):
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):
7685
"""High-level composed Q10 map view.
7786
7887
The latest map and trace packets are combined with the injected
7988
:class:`MapDpsTrait` whenever any of those three sources changes.
8089
"""
8190

91+
_CONVERTER = DpsDataConverter.from_dataclass(MapListDps)
92+
8293
def __init__(
8394
self,
8495
map_dps: MapDpsTrait,
96+
command: CommandTrait | None = None,
8597
*,
8698
map_parser_config: B01Q10MapParserConfig | None = None,
8799
) -> None:
100+
MapListDps.__init__(self)
88101
TraitUpdateListener.__init__(self, logger=_LOGGER)
89102
self._config = map_parser_config or B01Q10MapParserConfig()
90103
self._map_dps = map_dps
104+
self._command = command
91105
self._map_packet: Q10MapPacket | None = None
92106
self._trace_packet: Q10TracePacket | None = None
93107
self._image_content: bytes | None = None
94108
self._map_packet_callbacks: CallbackList[None] = CallbackList(_LOGGER)
95109
self._trace_packet_callbacks: CallbackList[None] = CallbackList(_LOGGER)
96110
self._map_dps.add_update_listener(self._map_dps_updated)
97111

112+
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")
116+
await self._command.send(
117+
B01_Q10_DP.COMMON,
118+
{str(B01_Q10_DP.MULTI_MAP.code): {"op": "list"}},
119+
)
120+
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+
98144
@property
99145
def image_content(self) -> bytes | None:
100146
"""The composed map PNG, if the latest map rendered successfully."""

0 commit comments

Comments
 (0)