Skip to content

Commit 9a6161b

Browse files
feat: compose Q10 map content from grouped traits
1 parent deeb775 commit 9a6161b

3 files changed

Lines changed: 277 additions & 87 deletions

File tree

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from .consumable import ConsumableTrait
1717
from .do_not_disturb import DoNotDisturbTrait
1818
from .dust_collection import DustCollectionTrait
19-
from .map import MapContentTrait
19+
from .map import MapContentTrait, MapDpsTrait
2020
from .network_info import NetworkInfoTrait
2121
from .remote import RemoteTrait
2222
from .status import StatusTrait
@@ -32,6 +32,7 @@
3232
"DoNotDisturbTrait",
3333
"DustCollectionTrait",
3434
"MapContentTrait",
35+
"MapDpsTrait",
3536
"NetworkInfoTrait",
3637
"SoundVolumeTrait",
3738
"StatusTrait",
@@ -79,6 +80,9 @@ class Q10PropertiesApi(Trait):
7980
map: MapContentTrait
8081
"""Trait for fetching the current parsed map (image + rooms)."""
8182

83+
map_dps: MapDpsTrait
84+
"""Low-level DPS values used to compose map overlays."""
85+
8286
clean_history: CleanHistoryTrait
8387
"""Trait for fetching the device clean-record history (``dpCleanRecord``)."""
8488

@@ -96,7 +100,8 @@ def __init__(self, channel: B01Q10Channel) -> None:
96100
self.button_light = ButtonLightTrait(self.command)
97101
self.network_info = NetworkInfoTrait()
98102
self.consumable = ConsumableTrait()
99-
self.map = MapContentTrait()
103+
self.map_dps = MapDpsTrait()
104+
self.map = MapContentTrait(self.map_dps)
100105
self.clean_history = CleanHistoryTrait(self.command)
101106
# Read-model traits updated from the device's DPS push stream.
102107
self._updatable_traits = [
@@ -108,6 +113,7 @@ def __init__(self, channel: B01Q10Channel) -> None:
108113
self.network_info,
109114
self.consumable,
110115
self.clean_history,
116+
self.map_dps,
111117
]
112118
self._subscribe_task: asyncio.Task[None] | None = None
113119

Lines changed: 102 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,110 +1,144 @@
1-
"""Map content trait for B01 Q10 devices.
2-
3-
Unlike the v1 / Q7 maps, the Q10 has no synchronous "get map" command, so this
4-
trait is purely push-driven and mirrors the Q10 ``StatusTrait`` contract:
5-
6-
- The device pushes its current map/path as protocol-301 ``MAP_RESPONSE``
7-
messages (a ``dpRequestDps`` nudges it to do so). The protocol layer decodes
8-
those into :class:`Q10MapPacket` / :class:`Q10TracePacket` objects and the
9-
``Q10PropertiesApi`` subscribe loop routes them to
10-
:meth:`MapContentTrait.update_from_map_packet` /
11-
:meth:`MapContentTrait.update_from_trace_packet`.
12-
- Those methods render/cache the content and notify update listeners (register
13-
via :meth:`add_update_listener`).
14-
- ``image_content``, ``map_data``, ``rooms``, ``path`` and ``robot_position``
15-
are readable and reflect the most recently pushed map.
16-
17-
Unlike the Q7, the Q10 map payload is unencrypted, so no map key is required.
1+
"""Push-driven map traits for B01 Q10 devices.
2+
3+
Map-related state arrives on three independent streams:
4+
5+
* map packets are decoded from map-protocol responses;
6+
* trace packets are decoded from trace-protocol responses;
7+
* restricted zones and virtual walls arrive as ordinary DPS values.
8+
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.
1814
"""
1915

2016
import logging
2117
from dataclasses import dataclass, field
2218

23-
from vacuum_map_parser_base.map_data import MapData
24-
2519
from roborock.data import RoborockBase
26-
from roborock.devices.traits.common import TraitUpdateListener
20+
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP
21+
from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener
22+
from roborock.exceptions import RoborockException
2723
from roborock.map.b01_q10_map_parser import (
28-
B01Q10MapParser,
2924
B01Q10MapParserConfig,
3025
Q10MapPacket,
3126
Q10Point,
3227
Q10Room,
3328
Q10TracePacket,
3429
)
30+
from roborock.map.b01_q10_overlays import Q10Zone, parse_virtual_wall_blob, parse_zone_blob
31+
from roborock.map.b01_q10_render import Q10MapOverlays, render_q10_map
3532

36-
_LOGGER = logging.getLogger(__name__)
33+
from .common import UpdatableTrait
3734

38-
_TRUNCATE_LENGTH = 20
35+
_LOGGER = logging.getLogger(__name__)
3936

4037

4138
@dataclass
42-
class MapContent(RoborockBase):
43-
"""Dataclass representing Q10 map content."""
39+
class MapDps(RoborockBase):
40+
"""Low-level map values delivered in the Q10 DPS stream."""
4441

45-
image_content: bytes | None = None
46-
"""The rendered image of the map in PNG format."""
42+
restricted_zone_up: str | None = field(default=None, metadata={"dps": B01_Q10_DP.RESTRICTED_ZONE_UP})
43+
virtual_wall_up: str | None = field(default=None, metadata={"dps": B01_Q10_DP.VIRTUAL_WALL_UP})
4744

48-
map_data: MapData | None = None
49-
"""Parsed map data (image metadata + room names)."""
5045

51-
rooms: list[Q10Room] = field(default_factory=list)
52-
"""Rooms (segments) reported by the device, with ids and names."""
46+
class MapDpsTrait(MapDps, UpdatableTrait):
47+
"""Converter-backed read model for map-related DPS values."""
5348

54-
path: list[Q10Point] = field(default_factory=list)
55-
"""Full path of the current cleaning session (oldest point first).
49+
_CONVERTER = DpsDataConverter.from_dataclass(MapDps)
5650

57-
The robot accumulates this server-side and serves the whole trajectory so
58-
far in one packet, so it is complete even if we connect mid-session. Only
59-
populated while a cleaning session is active."""
51+
def __init__(self) -> None:
52+
MapDps.__init__(self)
53+
UpdatableTrait.__init__(self, command=None, logger=_LOGGER)
6054

61-
robot_position: Q10Point | None = None
62-
"""Current robot position (the most recent path point), if known."""
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)
6359

64-
def __repr__(self) -> str:
65-
img = self.image_content
66-
if img and len(img) > _TRUNCATE_LENGTH:
67-
img = img[: _TRUNCATE_LENGTH - 3] + b"..."
68-
return f"MapContent(image_content={img!r}, rooms={self.rooms!r})"
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)
6964

7065

71-
class MapContentTrait(MapContent, TraitUpdateListener):
72-
"""Trait holding the most recently pushed parsed map content for Q10 devices.
66+
class MapContentTrait(TraitUpdateListener):
67+
"""High-level composed Q10 map view.
7368
74-
The Q10 has no synchronous get-map request; the device pushes map and trace
75-
packets, which the protocol layer decodes and the ``Q10PropertiesApi``
76-
subscribe loop feeds into :meth:`update_from_map_packet` /
77-
:meth:`update_from_trace_packet`. Consumers read the cached fields and/or
78-
register a callback with :meth:`add_update_listener` to be notified when new
79-
map content arrives.
69+
The latest map and trace packets are combined with the injected
70+
:class:`MapDpsTrait` whenever any of those three sources changes.
8071
"""
8172

8273
def __init__(
8374
self,
75+
map_dps: MapDpsTrait | None = None,
8476
*,
8577
map_parser_config: B01Q10MapParserConfig | None = None,
8678
) -> None:
87-
super().__init__()
8879
TraitUpdateListener.__init__(self, logger=_LOGGER)
89-
self._map_parser = B01Q10MapParser(map_parser_config)
80+
self._config = map_parser_config or B01Q10MapParserConfig()
81+
self._map_dps = map_dps or MapDpsTrait()
82+
self._map_packet: Q10MapPacket | None = None
83+
self._trace_packet: Q10TracePacket | None = None
84+
self._image_content: bytes | None = None
85+
self._map_dps.add_update_listener(self._map_dps_updated)
86+
87+
@property
88+
def image_content(self) -> bytes | None:
89+
"""The composed map PNG, if a map has been pushed."""
90+
return self._image_content
91+
92+
@property
93+
def rooms(self) -> list[Q10Room]:
94+
"""Rooms reported by the device."""
95+
return self._map_packet.rooms if self._map_packet else []
96+
97+
@property
98+
def path(self) -> list[Q10Point]:
99+
"""Full path from the latest trace packet."""
100+
return self._trace_packet.points if self._trace_packet else []
101+
102+
@property
103+
def robot_position(self) -> Q10Point | None:
104+
"""Current robot position from the latest trace packet."""
105+
return self._trace_packet.robot_position if self._trace_packet else None
106+
107+
@property
108+
def robot_heading(self) -> int | None:
109+
"""Current robot heading from the latest trace packet."""
110+
return self._trace_packet.heading if self._trace_packet else None
90111

91112
def update_from_map_packet(self, packet: Q10MapPacket) -> None:
92-
"""Render a pushed full-map packet into the cached image/rooms.
93-
94-
Rendering failures are logged and skipped (listeners are not notified) so
95-
a single bad push cannot tear down the subscribe loop.
96-
"""
97-
parsed = self._map_parser.parse_packet(packet)
98-
if parsed.image_content is None:
99-
_LOGGER.debug("Failed to render Q10 map image")
100-
return
101-
self.image_content = parsed.image_content
102-
self.map_data = parsed.map_data
103-
self.rooms = packet.rooms
113+
"""Store a map-protocol update and render the latest sources."""
114+
self._map_packet = packet
115+
self._render()
104116
self._notify_update()
105117

106118
def update_from_trace_packet(self, packet: Q10TracePacket) -> None:
107-
"""Cache the path/robot position from a pushed trace packet."""
108-
self.path = packet.points
109-
self.robot_position = packet.robot_position
119+
"""Store a trace-protocol update and render the latest sources."""
120+
self._trace_packet = packet
121+
self._render()
122+
self._notify_update()
123+
124+
def _map_dps_updated(self) -> None:
125+
"""Render after the low-level DPS source changes."""
126+
self._render()
110127
self._notify_update()
128+
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
133+
try:
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+
),
141+
config=self._config,
142+
)
143+
except RoborockException as ex:
144+
_LOGGER.debug("Failed to render Q10 map packet: %s", ex)

0 commit comments

Comments
 (0)