Skip to content

Commit 7c95182

Browse files
fix: harden Q10 map rendering state
1 parent deaf791 commit 7c95182

6 files changed

Lines changed: 320 additions & 43 deletions

File tree

roborock/cli.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,7 @@ async def maps(ctx, device_id: str):
603603
async def _await_q10_map_push(
604604
properties: Q10PropertiesApi,
605605
predicate: Callable[[], bool],
606+
generation: Callable[[], int],
606607
*,
607608
timeout: float = _Q10_MAP_PUSH_TIMEOUT,
608609
allow_cached_on_timeout: bool = False,
@@ -617,9 +618,10 @@ async def _await_q10_map_push(
617618
"""
618619
loop = asyncio.get_running_loop()
619620
updated: asyncio.Future[None] = loop.create_future()
621+
initial_generation = generation()
620622

621623
def on_update() -> None:
622-
if predicate() and not updated.done():
624+
if generation() != initial_generation and predicate() and not updated.done():
623625
updated.set_result(None)
624626

625627
unsub = properties.map.add_update_listener(on_update)
@@ -648,6 +650,7 @@ async def map_image(ctx, device_id: str, output_file: str):
648650
await _await_q10_map_push(
649651
properties,
650652
lambda: properties.map.image_content is not None,
653+
lambda: properties.map.map_generation,
651654
allow_cached_on_timeout=True,
652655
)
653656
image_content = properties.map.image_content
@@ -706,7 +709,11 @@ async def q10_position(ctx, device_id: str, include_path: bool):
706709
click.echo("Feature not supported by device")
707710
return
708711
properties = device.b01_q10_properties
709-
got_trace = await _await_q10_map_push(properties, lambda: bool(properties.map.path))
712+
got_trace = await _await_q10_map_push(
713+
properties,
714+
lambda: bool(properties.map.path),
715+
lambda: properties.map.trace_generation,
716+
)
710717
if not got_trace:
711718
click.echo("No live trace available (the robot only reports position while cleaning).")
712719
return
@@ -871,6 +878,7 @@ async def rooms(ctx, device_id: str):
871878
await _await_q10_map_push(
872879
properties,
873880
lambda: properties.map.image_content is not None,
881+
lambda: properties.map.map_generation,
874882
allow_cached_on_timeout=True,
875883
)
876884
click.echo(dump_json({room.id: room.name for room in properties.map.rooms}))

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

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
]
4141

4242
_LOGGER = logging.getLogger(__name__)
43+
_MAP_LIST_REQUEST_TIMEOUT = 30.0
4344

4445

4546
def _map_id_from_list_response(response: Any) -> int | str | None:
@@ -134,7 +135,9 @@ def __init__(self, channel: B01Q10Channel) -> None:
134135
self._map_dps,
135136
]
136137
self._subscribe_task: asyncio.Task[None] | None = None
137-
self._map_list_requested = False
138+
self._map_request_lock = asyncio.Lock()
139+
self._map_list_request_token: object | None = None
140+
self._map_list_requested_at: float | None = None
138141

139142
async def start(self) -> None:
140143
"""Start any necessary subscriptions for the trait."""
@@ -166,15 +169,27 @@ async def request_map(self) -> None:
166169
matching ``get`` command; the resulting protocol-301 map packet is
167170
routed to :attr:`map`.
168171
"""
169-
self._map_list_requested = True
170-
try:
171-
await self.command.send(
172-
B01_Q10_DP.COMMON,
173-
{str(B01_Q10_DP.MULTI_MAP.code): {"op": "list"}},
174-
)
175-
except RoborockException:
176-
self._map_list_requested = False
177-
raise
172+
async with self._map_request_lock:
173+
now = asyncio.get_running_loop().time()
174+
if (
175+
self._map_list_request_token is not None
176+
and self._map_list_requested_at is not None
177+
and now - self._map_list_requested_at < _MAP_LIST_REQUEST_TIMEOUT
178+
):
179+
return
180+
token = object()
181+
self._map_list_request_token = token
182+
self._map_list_requested_at = now
183+
try:
184+
await self.command.send(
185+
B01_Q10_DP.COMMON,
186+
{str(B01_Q10_DP.MULTI_MAP.code): {"op": "list"}},
187+
)
188+
except BaseException:
189+
if self._map_list_request_token is token:
190+
self._map_list_request_token = None
191+
self._map_list_requested_at = None
192+
raise
178193

179194
async def _subscribe_loop(self) -> None:
180195
"""Persistent loop dispatching decoded messages to the read-model traits."""
@@ -197,18 +212,25 @@ async def _handle_message(self, message: Q10Message) -> None:
197212
_LOGGER.debug("Received Q10 status update: %s", message.dps)
198213
# Notify all read-model traits about the new message; each trait
199214
# only updates the fields that it is responsible for.
200-
for trait in self._updatable_traits:
201-
trait.update_from_dps(message.dps)
215+
self.map.begin_source_update()
216+
try:
217+
for trait in self._updatable_traits:
218+
trait.update_from_dps(message.dps)
219+
finally:
220+
self.map.end_source_update()
202221
await self._request_map_from_list_response(message.dps)
203222

204223
async def _request_map_from_list_response(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
205224
"""Request map content after receiving our pending map-list response."""
206225
response = decoded_dps.get(B01_Q10_DP.MULTI_MAP)
207-
if not self._map_list_requested or not isinstance(response, dict) or response.get("op") != "list":
226+
if self._map_list_request_token is None or not isinstance(response, dict) or response.get("op") != "list":
208227
return
209228

210-
self._map_list_requested = False
229+
token = self._map_list_request_token
211230
if (map_id := _map_id_from_list_response(response)) is None:
231+
if self._map_list_request_token is token:
232+
self._map_list_request_token = None
233+
self._map_list_requested_at = None
212234
_LOGGER.debug("Q10 map list response did not contain a usable map ID")
213235
return
214236

@@ -225,6 +247,10 @@ async def _request_map_from_list_response(self, decoded_dps: dict[B01_Q10_DP, An
225247
except RoborockException as ex:
226248
# A failed follow-up must not kill the persistent subscribe loop.
227249
_LOGGER.debug("Failed to request Q10 map content: %s", ex)
250+
finally:
251+
if self._map_list_request_token is token:
252+
self._map_list_request_token = None
253+
self._map_list_requested_at = None
228254

229255

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

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

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from .status import StatusTrait
3838

3939
_LOGGER = logging.getLogger(__name__)
40+
_DOCKED_STATES = {YXDeviceState.CHARGING, YXDeviceState.EMPTYING_THE_BIN}
4041

4142

4243
@dataclass
@@ -91,10 +92,14 @@ def __init__(
9192
self._config = map_parser_config or B01Q10MapParserConfig()
9293
self._map_dps = map_dps
9394
self._status = status or StatusTrait()
94-
self._robot_at_dock = self._status.status == YXDeviceState.CHARGING
95+
self._robot_at_dock = self._status.status in _DOCKED_STATES
9596
self._map_packet: Q10MapPacket | None = None
9697
self._trace_packet: Q10TracePacket | None = None
9798
self._image_content: bytes | None = None
99+
self._map_generation = 0
100+
self._trace_generation = 0
101+
self._source_update_depth = 0
102+
self._source_update_pending = False
98103
self._map_dps.add_update_listener(self._map_dps_updated)
99104
self._status.add_update_listener(self._status_updated)
100105

@@ -123,35 +128,71 @@ def robot_heading(self) -> int | None:
123128
"""Current heading for orienting a robot marker on a caller-rendered map."""
124129
return self._trace_packet.heading if self._trace_packet else None
125130

131+
@property
132+
def map_generation(self) -> int:
133+
"""Number of map packets received by this trait."""
134+
return self._map_generation
135+
136+
@property
137+
def trace_generation(self) -> int:
138+
"""Number of trace packets received by this trait."""
139+
return self._trace_generation
140+
126141
def update_from_map_packet(self, packet: Q10MapPacket) -> None:
127142
"""Store a map-protocol update and render the latest sources."""
128143
self._map_packet = packet
144+
self._map_generation += 1
129145
self._render()
130146
self._notify_update()
131147

132148
def update_from_trace_packet(self, packet: Q10TracePacket) -> None:
133149
"""Store a trace-protocol update and render the latest sources."""
134-
self._trace_packet = packet
150+
# A late packet from the completed clean cannot move a robot that the
151+
# status stream already confirmed is docked.
152+
self._trace_packet = None if self._robot_at_dock else packet
153+
self._trace_generation += 1
135154
self._render()
136155
self._notify_update()
137156

138-
def _map_dps_updated(self) -> None:
139-
"""Render after the low-level map DPS source changes."""
140-
if self._map_packet is None:
157+
def begin_source_update(self) -> None:
158+
"""Defer dependent rendering while one DPS message is applied."""
159+
self._source_update_depth += 1
160+
161+
def end_source_update(self) -> None:
162+
"""Render once after all traits consumed the same DPS message."""
163+
self._source_update_depth -= 1
164+
if self._source_update_depth == 0 and self._source_update_pending:
165+
self._source_update_pending = False
166+
self._render_and_notify()
167+
168+
def _source_updated(self, *, notify_without_map: bool = False) -> None:
169+
"""Render now, or defer until the current DPS update is complete."""
170+
if self._map_packet is None and not notify_without_map:
141171
return
172+
if self._source_update_depth:
173+
self._source_update_pending = True
174+
return
175+
self._render_and_notify()
176+
177+
def _render_and_notify(self) -> None:
178+
"""Recompose the current map and publish one update."""
142179
self._render()
143180
self._notify_update()
144181

182+
def _map_dps_updated(self) -> None:
183+
"""Render after the low-level map DPS source changes."""
184+
self._source_updated()
185+
145186
def _status_updated(self) -> None:
146187
"""Render only when the status changes whether the robot is docked."""
147-
robot_at_dock = self._status.status == YXDeviceState.CHARGING
188+
robot_at_dock = self._status.status in _DOCKED_STATES
148189
if robot_at_dock == self._robot_at_dock:
149190
return
150191
self._robot_at_dock = robot_at_dock
151-
if self._map_packet is None:
152-
return
153-
self._render()
154-
self._notify_update()
192+
trace_cleared = robot_at_dock and self._trace_packet is not None
193+
if robot_at_dock:
194+
self._trace_packet = None
195+
self._source_updated(notify_without_map=trace_cleared)
155196

156197
def _render(self) -> None:
157198
"""Render the required map with the latest optional trace and overlays."""

roborock/map/map_parser.py

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import io
44
import logging
5+
import threading
56
from dataclasses import dataclass, field
67

78
from vacuum_map_parser_base.config.color import Color, ColorsPalette, SupportedColor
@@ -105,10 +106,18 @@ def parse(self, map_bytes: bytes) -> ParsedMapData | None:
105106
class _AdjacencyAwareRoborockImageParser(RoborockImageParser):
106107
"""Apply the shared adjacency color policy to V1 room cells."""
107108

108-
def __init__(self, palette: ColorsPalette, image_config: ImageConfig) -> None:
109+
def __init__(
110+
self,
111+
palette: ColorsPalette,
112+
image_config: ImageConfig,
113+
*,
114+
recolor_rooms: bool = True,
115+
) -> None:
109116
super().__init__(palette, image_config)
110117
self._room_palette = palette
111118
self._base_room_colors = palette.cached_room_colors.copy()
119+
self._recolor_rooms = recolor_rooms
120+
self._palette_lock = threading.Lock()
112121

113122
def parse(
114123
self,
@@ -119,19 +128,22 @@ def parse(
119128
removed_map: set[int] | None = None,
120129
):
121130
"""Assign non-conflicting room colors before the V1 image pass."""
122-
self._room_palette.cached_room_colors.clear()
123-
self._room_palette.cached_room_colors.update(self._base_room_colors)
131+
with self._palette_lock:
132+
self._room_palette.cached_room_colors.clear()
133+
self._room_palette.cached_room_colors.update(self._base_room_colors)
134+
135+
if self._recolor_rooms:
124136

125-
def room_id(value: int) -> int | None:
126-
if value in (self.MAP_OUTSIDE, self.MAP_WALL, self.MAP_INSIDE, self.MAP_SCAN):
127-
return None
128-
return self._get_room_number(value) if value & 0x07 == 0x07 else None
137+
def room_id(value: int) -> int | None:
138+
if value in (self.MAP_OUTSIDE, self.MAP_WALL, self.MAP_INSIDE, self.MAP_SCAN):
139+
return None
140+
return self._get_room_number(value) if value & 0x07 == 0x07 else None
129141

130-
room_colors = adjacency_aware_room_colors(raw_data, width, self._room_palette, room_id)
131-
for number, color in room_colors.items():
132-
self._room_palette.cached_room_colors[number] = color
133-
self._room_palette.cached_room_colors[str(number)] = color
134-
return super().parse(raw_data, width, height, carpet_map, removed_map)
142+
room_colors = adjacency_aware_room_colors(raw_data, width, self._room_palette, room_id)
143+
for number, color in room_colors.items():
144+
self._room_palette.cached_room_colors[number] = color
145+
self._room_palette.cached_room_colors[str(number)] = color
146+
return super().parse(raw_data, width, height, carpet_map, removed_map)
135147

136148

137149
def _create_map_data_parser(config: MapParserConfig) -> RoborockMapDataParser:
@@ -144,7 +156,11 @@ def _create_map_data_parser(config: MapParserConfig) -> RoborockMapDataParser:
144156
image_config,
145157
[],
146158
)
147-
parser._image_parser = _AdjacencyAwareRoborockImageParser(palette, image_config)
159+
parser._image_parser = _AdjacencyAwareRoborockImageParser(
160+
palette,
161+
image_config,
162+
recolor_rooms=config.show_rooms,
163+
)
148164
return parser
149165

150166

@@ -184,7 +200,7 @@ def _create_rendering_components(
184200
color_dicts[SupportedColor.MAP_WALL_V2] = (0, 0, 0, 0)
185201

186202
if not config.show_rooms:
187-
room_colors = {str(x): (0, 0, 0, 0) for x in range(1, 32)}
203+
room_colors = {str(room_id): (0, 0, 0, 0) for room_id in map(int, ColorsPalette.ROOM_COLORS)}
188204

189205
return (
190206
ColorsPalette(color_dicts, room_colors),

0 commit comments

Comments
 (0)