Skip to content

Commit 62adc2b

Browse files
refactor: simplify Q10 map trait composition
1 parent e9a2087 commit 62adc2b

3 files changed

Lines changed: 46 additions & 102 deletions

File tree

roborock/cli.py

Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -721,39 +721,6 @@ async def q10_position(ctx, device_id: str, include_path: bool):
721721
click.echo(dump_json(summary))
722722

723723

724-
@session.command()
725-
@click.option("--device_id", required=True)
726-
@click.option("--output-file", required=True, help="Path to save the map image with the path drawn.")
727-
@click.pass_context
728-
@async_command
729-
async def q10_map_with_path(ctx, device_id: str, output_file: str):
730-
"""Render the Q10 map with the current cleaning path + robot position drawn.
731-
732-
Needs the robot to be actively cleaning so a live trace is available.
733-
Fetches the map and path and writes the annotated PNG.
734-
"""
735-
context: RoborockContext = ctx.obj
736-
device_manager = await context.get_device_manager()
737-
device = await device_manager.get_device(device_id)
738-
if device.b01_q10_properties is None:
739-
click.echo("Feature not supported by device")
740-
return
741-
properties = device.b01_q10_properties
742-
map_trait = properties.map
743-
await _await_q10_map_push(properties, lambda: map_trait.image_content is not None)
744-
got_path = await _await_q10_map_push(properties, lambda: bool(map_trait.path))
745-
if not got_path:
746-
click.echo("No live path available (the robot only reports its path while cleaning).")
747-
return
748-
image = map_trait.image_content
749-
if image is None:
750-
click.echo("No map image content available.")
751-
return
752-
with open(output_file, "wb") as f:
753-
f.write(image)
754-
click.echo(f"Saved map with {len(map_trait.path)}-point path to {output_file}")
755-
756-
757724
@session.command()
758725
@click.option("--device_id", required=True)
759726
@click.pass_context

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

Lines changed: 38 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@
99
``MapDpsTrait`` owns the low-level DPS read model. ``MapContentTrait`` depends
1010
on it and combines that state with the latest map/trace packets through the pure
1111
functions in :mod:`roborock.map.b01_q10_render`. The high-level trait keeps only
12-
one grouped source snapshot and one replace-whole rendered image; calibration,
13-
path placement and overlay placement are not independently mutable trait state.
12+
the latest value from each source and one replace-whole rendered image;
13+
calibration, path placement and overlay placement remain inside the renderer.
1414
"""
1515

1616
import logging
17-
from dataclasses import dataclass, field, replace
17+
from dataclasses import dataclass, field
1818

1919
from roborock.data import RoborockBase
2020
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
@@ -52,21 +52,22 @@ def __init__(self) -> None:
5252
MapDps.__init__(self)
5353
UpdatableTrait.__init__(self, command=None, logger=_LOGGER)
5454

55+
@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)
5559

56-
@dataclass(frozen=True)
57-
class Q10MapSource:
58-
"""The latest map-protocol inputs, replaced atomically on every push."""
59-
60-
map_packet: Q10MapPacket | None = None
61-
trace_packet: Q10TracePacket | None = None
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)
6264

6365

6466
class MapContentTrait(TraitUpdateListener):
6567
"""High-level composed Q10 map view.
6668
67-
Map and trace packet updates replace :attr:`_source`; DPS updates are owned
68-
by the injected :class:`MapDpsTrait`. Rendering always produces one new
69-
image, keeping the externally visible fields consistent.
69+
The latest map and trace packets are combined with the injected
70+
:class:`MapDpsTrait` whenever any of those three sources changes.
7071
"""
7172

7273
def __init__(
@@ -78,7 +79,8 @@ def __init__(
7879
TraitUpdateListener.__init__(self, logger=_LOGGER)
7980
self._config = map_parser_config or B01Q10MapParserConfig()
8081
self._map_dps = map_dps or MapDpsTrait()
81-
self._source = Q10MapSource()
82+
self._map_packet: Q10MapPacket | None = None
83+
self._trace_packet: Q10TracePacket | None = None
8284
self._image_content: bytes | None = None
8385
self._map_dps.add_update_listener(self._map_dps_updated)
8486

@@ -90,77 +92,53 @@ def image_content(self) -> bytes | None:
9092
@property
9193
def rooms(self) -> list[Q10Room]:
9294
"""Rooms reported by the device."""
93-
packet = self._source.map_packet
94-
return packet.rooms if packet else []
95+
return self._map_packet.rooms if self._map_packet else []
9596

9697
@property
9798
def path(self) -> list[Q10Point]:
9899
"""Full path from the latest trace packet."""
99-
trace = self._source.trace_packet
100-
return trace.points if trace else []
100+
return self._trace_packet.points if self._trace_packet else []
101101

102102
@property
103103
def robot_position(self) -> Q10Point | None:
104104
"""Current robot position from the latest trace packet."""
105-
trace = self._source.trace_packet
106-
return trace.robot_position if trace else None
105+
return self._trace_packet.robot_position if self._trace_packet else None
107106

108107
@property
109108
def robot_heading(self) -> int | None:
110109
"""Current robot heading from the latest trace packet."""
111-
trace = self._source.trace_packet
112-
return trace.heading if trace else None
113-
114-
@property
115-
def zones(self) -> list[Q10Zone]:
116-
"""Restricted zones decoded from the low-level DPS trait."""
117-
return parse_zone_blob(self._map_dps.restricted_zone_up)
118-
119-
@property
120-
def virtual_walls(self) -> list[Q10Zone]:
121-
"""Virtual walls decoded from the low-level DPS trait."""
122-
return parse_virtual_wall_blob(self._map_dps.virtual_wall_up)
110+
return self._trace_packet.heading if self._trace_packet else None
123111

124112
def update_from_map_packet(self, packet: Q10MapPacket) -> None:
125-
"""Replace the current map packet and compose a consistent result."""
126-
source = replace(self._source, map_packet=packet)
127-
render = self._compose(source)
128-
if render is None:
129-
return
130-
self._source = source
131-
self._image_content = render
113+
"""Store a map-protocol update and render the latest sources."""
114+
self._map_packet = packet
115+
self._render()
132116
self._notify_update()
133117

134118
def update_from_trace_packet(self, packet: Q10TracePacket) -> None:
135-
"""Replace the complete current-session trace packet."""
136-
self._source = replace(self._source, trace_packet=packet)
137-
if self._source.map_packet is not None:
138-
self._rebuild()
119+
"""Store a trace-protocol update and render the latest sources."""
120+
self._trace_packet = packet
121+
self._render()
139122
self._notify_update()
140123

141124
def _map_dps_updated(self) -> None:
142-
"""Recompose placed overlays after the low-level DPS state changes."""
143-
if self._source.map_packet is not None:
144-
self._rebuild()
125+
"""Render after the low-level DPS source changes."""
126+
self._render()
145127
self._notify_update()
146128

147-
def _compose(self, source: Q10MapSource) -> bytes | None:
148-
"""Compose a source snapshot, preserving the previous result on error."""
149-
if source.map_packet is None:
150-
return None
129+
def _render(self) -> None:
130+
"""Render the latest map, trace and DPS sources, if a map is available."""
131+
if self._map_packet is None:
132+
return
151133
try:
152-
return render_q10_map(
153-
source.map_packet,
154-
source.trace_packet,
155-
Q10MapOverlays(zones=tuple(self.zones), virtual_walls=tuple(self.virtual_walls)),
134+
self._image_content = render_q10_map(
135+
self._map_packet,
136+
self._trace_packet,
137+
Q10MapOverlays(
138+
zones=tuple(self._map_dps.zones),
139+
virtual_walls=tuple(self._map_dps.virtual_walls),
140+
),
156141
config=self._config,
157142
)
158143
except RoborockException as ex:
159144
_LOGGER.debug("Failed to render Q10 map packet: %s", ex)
160-
return None
161-
162-
def _rebuild(self) -> None:
163-
"""Replace the derived render from the current source snapshot."""
164-
render = self._compose(self._source)
165-
if render is not None:
166-
self._image_content = render

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

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ def test_short_trace_without_header_cannot_be_projected() -> None:
256256

257257

258258
def test_load_overlays_places_zones_after_calibration() -> None:
259-
"""Decoded no-go / no-mop zones are placed on MapData once calibrated."""
259+
"""Decoded no-go / no-mop zones are drawn once the sources calibrate."""
260260
map_dps = MapDpsTrait()
261261
trait = MapContentTrait(map_dps)
262262
packet = replace(parse_map_packet(FIXTURE.read_bytes()), header_calibration=_USABLE_HEADER)
@@ -275,25 +275,24 @@ def rect(zone_type: int, corners: list[tuple[int, int]]) -> bytes:
275275
blob = bytes([1, 1]) + rect(0, [(0, 0), (40, 0), (40, 40), (0, 40)])
276276
map_dps.update_from_dps({B01_Q10_DP.RESTRICTED_ZONE_UP: base64.b64encode(blob).decode()})
277277

278-
assert len(trait.zones) == 1
278+
assert len(map_dps.zones) == 1
279279
assert trait.image_content != before
280280

281281

282282
def test_load_overlays_partial_update_keeps_existing_zones() -> None:
283283
"""A status push without the zone DP (None) must not wipe loaded zones."""
284284
map_dps = MapDpsTrait()
285-
trait = MapContentTrait(map_dps)
286285
blob = (
287286
bytes([1, 1])
288287
+ bytes([0, 4])
289288
+ b"".join(int.to_bytes(v & 0xFFFF, 2, "big") for xy in [(0, 0), (4, 0), (4, 4), (0, 4)] for v in xy)
290289
)
291290
map_dps.update_from_dps({B01_Q10_DP.RESTRICTED_ZONE_UP: base64.b64encode(blob).decode()})
292-
assert len(trait.zones) == 1
291+
assert len(map_dps.zones) == 1
293292
# A later partial update carrying only the (empty) virtual-wall DP.
294293
map_dps.update_from_dps({B01_Q10_DP.VIRTUAL_WALL_UP: "AA=="})
295-
assert len(trait.zones) == 1 # zones preserved
296-
assert trait.virtual_walls == []
294+
assert len(map_dps.zones) == 1 # zones preserved
295+
assert map_dps.virtual_walls == []
297296

298297

299298
def test_map_dps_trait_updates_high_level_map_content() -> None:
@@ -310,7 +309,7 @@ def test_map_dps_trait_updates_high_level_map_content() -> None:
310309

311310
map_dps.update_from_dps({B01_Q10_DP.RESTRICTED_ZONE_UP: base64.b64encode(blob).decode()})
312311

313-
assert len(trait.zones) == 1
312+
assert len(map_dps.zones) == 1
314313
assert notified # listeners learn the overlays changed
315314

316315

@@ -323,6 +322,6 @@ def test_map_dps_push_without_overlay_data_points_is_noop() -> None:
323322

324323
map_dps.update_from_dps({B01_Q10_DP.BATTERY: 50})
325324

326-
assert trait.zones == []
327-
assert trait.virtual_walls == []
325+
assert map_dps.zones == []
326+
assert map_dps.virtual_walls == []
328327
assert not notified

0 commit comments

Comments
 (0)