Skip to content

Commit 033aabc

Browse files
refactor: simplify Q10 map trait state
1 parent b053bf1 commit 033aabc

3 files changed

Lines changed: 58 additions & 31 deletions

File tree

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

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
"DoNotDisturbTrait",
3333
"DustCollectionTrait",
3434
"MapContentTrait",
35-
"MapDpsTrait",
3635
"NetworkInfoTrait",
3736
"SoundVolumeTrait",
3837
"StatusTrait",
@@ -80,8 +79,8 @@ class Q10PropertiesApi(Trait):
8079
map: MapContentTrait
8180
"""Composed map image plus caller-facing map and trace data."""
8281

83-
map_dps: MapDpsTrait
84-
"""Restricted zones and virtual walls received through DPS."""
82+
_map_dps: MapDpsTrait
83+
"""Private source of restricted zones and virtual walls received through DPS."""
8584

8685
clean_history: CleanHistoryTrait
8786
"""Trait for fetching the device clean-record history (``dpCleanRecord``)."""
@@ -100,8 +99,8 @@ def __init__(self, channel: B01Q10Channel) -> None:
10099
self.button_light = ButtonLightTrait(self.command)
101100
self.network_info = NetworkInfoTrait()
102101
self.consumable = ConsumableTrait()
103-
self.map_dps = MapDpsTrait()
104-
self.map = MapContentTrait(self.map_dps)
102+
self._map_dps = MapDpsTrait()
103+
self.map = MapContentTrait(self._map_dps)
105104
self.clean_history = CleanHistoryTrait(self.command)
106105
# Read-model traits updated from the device's DPS push stream.
107106
self._updatable_traits = [
@@ -113,7 +112,7 @@ def __init__(self, channel: B01Q10Channel) -> None:
113112
self.network_info,
114113
self.consumable,
115114
self.clean_history,
116-
self.map_dps,
115+
self._map_dps,
117116
]
118117
self._subscribe_task: asyncio.Task[None] | None = None
119118

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

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import logging
1717
from dataclasses import dataclass, field
18+
from typing import Any
1819

1920
from roborock.data import RoborockBase
2021
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
@@ -27,7 +28,7 @@
2728
Q10Room,
2829
Q10TracePacket,
2930
)
30-
from roborock.map.b01_q10_overlays import Q10Zone, parse_virtual_wall_blob, parse_zone_blob
31+
from roborock.map.b01_q10_overlays import parse_virtual_wall_blob, parse_zone_blob
3132
from roborock.map.b01_q10_render import Q10MapOverlays, render_q10_map
3233

3334
from .common import UpdatableTrait
@@ -44,23 +45,29 @@ class MapDps(RoborockBase):
4445

4546

4647
class MapDpsTrait(MapDps, UpdatableTrait):
47-
"""Converter-backed read model for map-related DPS values."""
48+
"""Private read model for map-related DPS values and decoded overlays."""
4849

4950
_CONVERTER = DpsDataConverter.from_dataclass(MapDps)
5051

5152
def __init__(self) -> None:
5253
MapDps.__init__(self)
5354
UpdatableTrait.__init__(self, command=None, logger=_LOGGER)
55+
self._overlays = Q10MapOverlays()
5456

5557
@property
56-
def zones(self) -> list[Q10Zone]:
57-
"""Restricted zones decoded from the latest DPS value."""
58-
return parse_zone_blob(self.restricted_zone_up)
58+
def overlays(self) -> Q10MapOverlays:
59+
"""Overlays decoded once from the latest relevant DPS update."""
60+
return self._overlays
5961

60-
@property
61-
def virtual_walls(self) -> list[Q10Zone]:
62-
"""Virtual walls decoded from the latest DPS value."""
63-
return parse_virtual_wall_blob(self.virtual_wall_up)
62+
def update_from_dps(self, decoded_dps: dict[B01_Q10_DP, Any]) -> None:
63+
"""Decode overlay blobs when they arrive, then notify dependents."""
64+
if not self._CONVERTER.update_from_dps(self, decoded_dps):
65+
return
66+
self._overlays = Q10MapOverlays(
67+
zones=tuple(parse_zone_blob(self.restricted_zone_up)),
68+
virtual_walls=tuple(parse_virtual_wall_blob(self.virtual_wall_up)),
69+
)
70+
self._notify_update()
6471

6572

6673
class MapContentTrait(TraitUpdateListener):
@@ -96,17 +103,17 @@ def rooms(self) -> list[Q10Room]:
96103

97104
@property
98105
def path(self) -> list[Q10Point]:
99-
"""Full path from the latest trace packet."""
106+
"""Full path for live status and callers drawing their own map overlay."""
100107
return self._trace_packet.points if self._trace_packet else []
101108

102109
@property
103110
def robot_position(self) -> Q10Point | None:
104-
"""Current robot position from the latest trace packet."""
111+
"""Current position for live status and caller-rendered map overlays."""
105112
return self._trace_packet.robot_position if self._trace_packet else None
106113

107114
@property
108115
def robot_heading(self) -> int | None:
109-
"""Current robot heading from the latest trace packet."""
116+
"""Current heading for orienting a robot marker on a caller-rendered map."""
110117
return self._trace_packet.heading if self._trace_packet else None
111118

112119
def update_from_map_packet(self, packet: Q10MapPacket) -> None:
@@ -129,17 +136,14 @@ def _map_dps_updated(self) -> None:
129136
self._notify_update()
130137

131138
def _render(self) -> None:
132-
"""Render the latest map, trace and DPS sources, if a map is available."""
139+
"""Render the required map with the latest optional trace and overlays."""
133140
if self._map_packet is None:
134141
return
135142
try:
136143
self._image_content = render_q10_map(
137144
self._map_packet,
138145
self._trace_packet,
139-
Q10MapOverlays(
140-
zones=tuple(self._map_dps.zones),
141-
virtual_walls=tuple(self._map_dps.virtual_walls),
142-
),
146+
self._map_dps.overlays,
143147
config=self._config,
144148
)
145149
except RoborockException as ex:

tests/devices/traits/b01/q10/test_map.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
parse_map_packet,
2929
parse_trace_packet,
3030
)
31+
from roborock.map.b01_q10_render import Q10MapOverlays
3132
from roborock.protocols.b01_q10_protocol import Q10Message
3233

3334
from .conftest import FakeB01Q10Channel
@@ -86,6 +87,13 @@ def test_q10_position_is_available_as_top_level_cli_command() -> None:
8687
assert "q10-position" in cli.commands
8788

8889

90+
def test_q10_map_dps_trait_is_private() -> None:
91+
api = create(FakeB01Q10Channel())
92+
93+
assert not hasattr(api, "map_dps")
94+
assert api._map_dps in api._updatable_traits
95+
96+
8997
# --- CLI push waiting --------------------------------------------------------
9098

9199

@@ -250,24 +258,41 @@ def test_map_dps_update_renders_decoded_overlays() -> None:
250258
notified.clear()
251259
map_dps.update_from_dps({B01_Q10_DP.RESTRICTED_ZONE_UP: _zone_blob()})
252260

253-
assert len(map_dps.zones) == 1
261+
assert len(map_dps.overlays.zones) == 1
254262
assert trait.image_content == b"image with overlays"
255263
assert notified == [None]
256264
assert render.call_count == 2
257265
assert render.call_args.args[0] is packet
258266
assert render.call_args.args[1] is None
259-
assert tuple(render.call_args.args[2].zones) == tuple(map_dps.zones)
267+
assert render.call_args.args[2] is map_dps.overlays
268+
269+
270+
def test_map_dps_blobs_are_decoded_only_when_dps_arrives() -> None:
271+
"""Map and trace renders reuse the overlays decoded by the DPS trait."""
272+
map_dps = MapDpsTrait()
273+
trait = MapContentTrait(map_dps)
274+
275+
with (
276+
patch("roborock.devices.traits.b01.q10.map.parse_zone_blob", return_value=[]) as parse_zones,
277+
patch("roborock.devices.traits.b01.q10.map.parse_virtual_wall_blob", return_value=[]) as parse_walls,
278+
):
279+
map_dps.update_from_dps({B01_Q10_DP.RESTRICTED_ZONE_UP: _zone_blob()})
280+
trait.update_from_map_packet(parse_map_packet(FIXTURE.read_bytes()))
281+
trait.update_from_trace_packet(parse_trace_packet(TRACE_SESSION_FIXTURE.read_bytes()))
282+
283+
parse_zones.assert_called_once_with(_zone_blob())
284+
parse_walls.assert_called_once_with(None)
260285

261286

262287
def test_load_overlays_partial_update_keeps_existing_zones() -> None:
263288
"""A status push without the zone DP (None) must not wipe loaded zones."""
264289
map_dps = MapDpsTrait()
265290
map_dps.update_from_dps({B01_Q10_DP.RESTRICTED_ZONE_UP: _zone_blob()})
266-
assert len(map_dps.zones) == 1
291+
assert len(map_dps.overlays.zones) == 1
267292
# A later partial update carrying only the (empty) virtual-wall DP.
268293
map_dps.update_from_dps({B01_Q10_DP.VIRTUAL_WALL_UP: "AA=="})
269-
assert len(map_dps.zones) == 1 # zones preserved
270-
assert map_dps.virtual_walls == []
294+
assert len(map_dps.overlays.zones) == 1 # zones preserved
295+
assert map_dps.overlays.virtual_walls == ()
271296

272297

273298
def test_map_dps_update_without_map_does_not_notify_map_content() -> None:
@@ -279,7 +304,7 @@ def test_map_dps_update_without_map_does_not_notify_map_content() -> None:
279304

280305
map_dps.update_from_dps({B01_Q10_DP.RESTRICTED_ZONE_UP: _zone_blob()})
281306

282-
assert len(map_dps.zones) == 1
307+
assert len(map_dps.overlays.zones) == 1
283308
assert not notified
284309

285310

@@ -292,6 +317,5 @@ def test_map_dps_push_without_overlay_data_points_is_noop() -> None:
292317

293318
map_dps.update_from_dps({B01_Q10_DP.BATTERY: 50})
294319

295-
assert map_dps.zones == []
296-
assert map_dps.virtual_walls == []
320+
assert map_dps.overlays == Q10MapOverlays()
297321
assert not notified

0 commit comments

Comments
 (0)