|
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. |
18 | 14 | """ |
19 | 15 |
|
20 | 16 | import logging |
21 | 17 | from dataclasses import dataclass, field |
22 | 18 |
|
23 | | -from vacuum_map_parser_base.map_data import MapData |
24 | | - |
25 | 19 | 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 |
27 | 23 | from roborock.map.b01_q10_map_parser import ( |
28 | | - B01Q10MapParser, |
29 | 24 | B01Q10MapParserConfig, |
30 | 25 | Q10MapPacket, |
31 | 26 | Q10Point, |
32 | 27 | Q10Room, |
33 | 28 | Q10TracePacket, |
34 | 29 | ) |
| 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 |
35 | 32 |
|
36 | | -_LOGGER = logging.getLogger(__name__) |
| 33 | +from .common import UpdatableTrait |
37 | 34 |
|
38 | | -_TRUNCATE_LENGTH = 20 |
| 35 | +_LOGGER = logging.getLogger(__name__) |
39 | 36 |
|
40 | 37 |
|
41 | 38 | @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.""" |
44 | 41 |
|
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}) |
47 | 44 |
|
48 | | - map_data: MapData | None = None |
49 | | - """Parsed map data (image metadata + room names).""" |
50 | 45 |
|
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.""" |
53 | 48 |
|
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) |
56 | 50 |
|
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) |
60 | 54 |
|
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) |
63 | 59 |
|
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) |
69 | 64 |
|
70 | 65 |
|
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. |
73 | 68 |
|
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. |
80 | 71 | """ |
81 | 72 |
|
82 | 73 | def __init__( |
83 | 74 | self, |
| 75 | + map_dps: MapDpsTrait | None = None, |
84 | 76 | *, |
85 | 77 | map_parser_config: B01Q10MapParserConfig | None = None, |
86 | 78 | ) -> None: |
87 | | - super().__init__() |
88 | 79 | 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 |
90 | 111 |
|
91 | 112 | 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() |
104 | 116 | self._notify_update() |
105 | 117 |
|
106 | 118 | 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() |
110 | 127 | 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