Skip to content

Commit e524d31

Browse files
allenporterCopilot
andauthored
feat: Add map content to the Home trait (#572)
* feat: Add map content to the Home trait This manages caching map content for the whole home, including performing discovery similar to metadata. * chore: Update tests/devices/traits/v1/test_home.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * chore: Update tests/devices/traits/v1/test_home.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * chore: Add test coverage for failing to parse bytes --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 91b0c42 commit e524d31

File tree

5 files changed

+257
-56
lines changed

5 files changed

+257
-56
lines changed

roborock/devices/cache.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ class CacheData:
2525
home_map_info: dict[int, CombinedMapInfo] = field(default_factory=dict)
2626
"""Home map information indexed by map_flag."""
2727

28+
home_map_content: dict[int, bytes] = field(default_factory=dict)
29+
"""Home cache content for each map data indexed by map_flag."""
30+
2831
device_features: DeviceFeatures | None = None
2932
"""Device features information."""
3033

roborock/devices/traits/v1/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ def __init__(
141141
self.rooms = RoomsTrait(home_data)
142142
self.maps = MapsTrait(self.status)
143143
self.map_content = MapContentTrait(map_parser_config)
144-
self.home = HomeTrait(self.status, self.maps, self.rooms, cache)
144+
self.home = HomeTrait(self.status, self.maps, self.map_content, self.rooms, cache)
145145
self.device_features = DeviceFeaturesTrait(product.product_nickname, cache)
146146
self.network_info = NetworkInfoTrait(device_uid, cache)
147147

roborock/devices/traits/v1/home.py

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from roborock.exceptions import RoborockDeviceBusy, RoborockException
2121
from roborock.roborock_typing import RoborockCommand
2222

23+
from .map_content import MapContent, MapContentTrait
2324
from .maps import MapsTrait
2425
from .rooms import RoomsTrait
2526
from .status import StatusTrait
@@ -38,6 +39,7 @@ def __init__(
3839
self,
3940
status_trait: StatusTrait,
4041
maps_trait: MapsTrait,
42+
map_content: MapContentTrait,
4143
rooms_trait: RoomsTrait,
4244
cache: Cache,
4345
) -> None:
@@ -59,9 +61,11 @@ def __init__(
5961
super().__init__()
6062
self._status_trait = status_trait
6163
self._maps_trait = maps_trait
64+
self._map_content = map_content
6265
self._rooms_trait = rooms_trait
6366
self._cache = cache
6467
self._home_map_info: dict[int, CombinedMapInfo] | None = None
68+
self._home_map_content: dict[int, MapContent] | None = None
6569

6670
async def discover_home(self) -> None:
6771
"""Iterate through all maps to discover rooms and cache them.
@@ -75,10 +79,18 @@ async def discover_home(self) -> None:
7579
After discovery, the home cache will be populated and can be accessed via the `home_map_info` property.
7680
"""
7781
cache_data = await self._cache.get()
78-
if cache_data.home_map_info:
82+
if cache_data.home_map_info and cache_data.home_map_content:
7983
_LOGGER.debug("Home cache already populated, skipping discovery")
8084
self._home_map_info = cache_data.home_map_info
81-
return
85+
try:
86+
self._home_map_content = {
87+
k: self._map_content.parse_map_content(v) for k, v in cache_data.home_map_content.items()
88+
}
89+
except (ValueError, RoborockException) as ex:
90+
_LOGGER.warning("Failed to parse cached home map content, will re-discover: %s", ex)
91+
self._home_map_content = {}
92+
else:
93+
return
8294

8395
if self._status_trait.state == RoborockStateCode.cleaning:
8496
raise RoborockDeviceBusy("Cannot perform home discovery while the device is cleaning")
@@ -87,9 +99,9 @@ async def discover_home(self) -> None:
8799
if self._maps_trait.current_map_info is None:
88100
raise RoborockException("Cannot perform home discovery without current map info")
89101

90-
home_map_info = await self._build_home_map_info()
102+
home_map_info, home_map_content = await self._build_home_map_info()
91103
_LOGGER.debug("Home discovery complete, caching data for %d maps", len(home_map_info))
92-
await self._update_home_map_info(home_map_info)
104+
await self._update_home_cache(home_map_info, home_map_content)
93105

94106
async def _refresh_map_info(self, map_info) -> CombinedMapInfo:
95107
"""Collect room data for a specific map and return CombinedMapInfo."""
@@ -100,9 +112,19 @@ async def _refresh_map_info(self, map_info) -> CombinedMapInfo:
100112
rooms=self._rooms_trait.rooms or [],
101113
)
102114

103-
async def _build_home_map_info(self) -> dict[int, CombinedMapInfo]:
104-
"""Perform the actual discovery and caching of home data."""
115+
async def _refresh_map_content(self) -> MapContent:
116+
"""Refresh the map content trait to get the latest map data."""
117+
await self._map_content.refresh()
118+
return MapContent(
119+
image_content=self._map_content.image_content,
120+
map_data=self._map_content.map_data,
121+
raw_api_response=self._map_content.raw_api_response,
122+
)
123+
124+
async def _build_home_map_info(self) -> tuple[dict[int, CombinedMapInfo], dict[int, MapContent]]:
125+
"""Perform the actual discovery and caching of home map info and content."""
105126
home_map_info: dict[int, CombinedMapInfo] = {}
127+
home_map_content: dict[int, MapContent] = {}
106128

107129
# Sort map_info to process the current map last, reducing map switching.
108130
# False (non-original maps) sorts before True (original map). We ensure
@@ -120,9 +142,12 @@ async def _build_home_map_info(self) -> dict[int, CombinedMapInfo]:
120142
await self._maps_trait.set_current_map(map_info.map_flag)
121143
await asyncio.sleep(MAP_SLEEP)
122144

123-
map_data = await self._refresh_map_info(map_info)
124-
home_map_info[map_info.map_flag] = map_data
125-
return home_map_info
145+
map_content = await self._refresh_map_content()
146+
home_map_content[map_info.map_flag] = map_content
147+
148+
combined_map_info = await self._refresh_map_info(map_info)
149+
home_map_info[map_info.map_flag] = combined_map_info
150+
return home_map_info, home_map_content
126151

127152
async def refresh(self) -> Self:
128153
"""Refresh current map's underlying map and room data, updating cache as needed.
@@ -141,13 +166,11 @@ async def refresh(self) -> Self:
141166
) is None:
142167
raise RoborockException("Cannot refresh home data without current map info")
143168

169+
# Refresh the map content to ensure we have the latest image and object positions
170+
new_map_content = await self._refresh_map_content()
144171
# Refresh the current map's room data
145-
current_map_data = self._home_map_info.get(map_flag)
146-
if current_map_data:
147-
map_data = await self._refresh_map_info(current_map_info)
148-
if map_data != current_map_data:
149-
await self._update_home_map_info({**self._home_map_info, map_flag: map_data})
150-
172+
combined_map_info = await self._refresh_map_info(current_map_info)
173+
await self._update_current_map_cache(map_flag, combined_map_info, new_map_content)
151174
return self
152175

153176
@property
@@ -163,12 +186,36 @@ def current_map_data(self) -> CombinedMapInfo | None:
163186
return None
164187
return self._home_map_info.get(current_map_flag)
165188

189+
@property
190+
def home_map_content(self) -> dict[int, MapContent] | None:
191+
"""Returns the map content for all cached maps."""
192+
return self._home_map_content
193+
166194
def _parse_response(self, response: common.V1ResponseData) -> Self:
167195
"""This trait does not parse responses directly."""
168196
raise NotImplementedError("HomeTrait does not support direct command responses")
169197

170-
async def _update_home_map_info(self, home_map_info: dict[int, CombinedMapInfo]) -> None:
198+
async def _update_home_cache(
199+
self, home_map_info: dict[int, CombinedMapInfo], home_map_content: dict[int, MapContent]
200+
) -> None:
201+
"""Update the entire home cache with new map info and content."""
171202
cache_data = await self._cache.get()
172203
cache_data.home_map_info = home_map_info
204+
cache_data.home_map_content = {k: v.raw_api_response for k, v in home_map_content.items() if v.raw_api_response}
173205
await self._cache.set(cache_data)
174206
self._home_map_info = home_map_info
207+
self._home_map_content = home_map_content
208+
209+
async def _update_current_map_cache(
210+
self, map_flag: int, map_info: CombinedMapInfo, map_content: MapContent
211+
) -> None:
212+
"""Update the cache for the current map only."""
213+
cache_data = await self._cache.get()
214+
cache_data.home_map_info[map_flag] = map_info
215+
if map_content.raw_api_response:
216+
cache_data.home_map_content[map_flag] = map_content.raw_api_response
217+
await self._cache.set(cache_data)
218+
if self._home_map_info is None or self._home_map_content is None:
219+
raise RoborockException("Home cache is not initialized, cannot update current map cache")
220+
self._home_map_info[map_flag] = map_info
221+
self._home_map_content[map_flag] = map_content

roborock/devices/traits/v1/map_content.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ class MapContent(RoborockBase):
2525
map_data: MapData | None = None
2626
"""The parsed map data which contains metadata for points on the map."""
2727

28+
raw_api_response: bytes | None = None
29+
"""The raw bytes of the map data from the API for caching for future use.
30+
31+
This should be treated as an opaque blob used only internally by this library
32+
to re-parse the map data when needed.
33+
"""
34+
2835
def __repr__(self) -> str:
2936
"""Return a string representation of the MapContent."""
3037
img = self.image_content
@@ -48,12 +55,29 @@ def _parse_response(self, response: common.V1ResponseData) -> MapContent:
4855
"""Parse the response from the device into a MapContentTrait instance."""
4956
if not isinstance(response, bytes):
5057
raise ValueError(f"Unexpected MapContentTrait response format: {type(response)}")
58+
return self.parse_map_content(response)
59+
60+
def parse_map_content(self, response: bytes) -> MapContent:
61+
"""Parse the map content from raw bytes.
62+
63+
This method is exposed so that cached map data can be parsed without
64+
needing to go through the RPC channel.
65+
66+
Args:
67+
response: The raw bytes of the map data from the API.
68+
69+
Returns:
70+
MapContent: The parsed map content.
5171
72+
Raises:
73+
RoborockException: If the map data cannot be parsed.
74+
"""
5275
parsed_data = self._map_parser.parse(response)
5376
if parsed_data is None:
5477
raise ValueError("Failed to parse map data")
5578

5679
return MapContent(
5780
image_content=parsed_data.image_content,
5881
map_data=parsed_data.map_data,
82+
raw_api_response=response,
5983
)

0 commit comments

Comments
 (0)