Skip to content

Commit 3835295

Browse files
committed
feat: Add a Home trait that is responsible for caching information about maps and rooms
This will be updated to also handle map content storage in a followup.
1 parent 1b17325 commit 3835295

File tree

6 files changed

+524
-14
lines changed

6 files changed

+524
-14
lines changed

roborock/containers.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -830,6 +830,36 @@ class RoomMapping(RoborockBase):
830830
iot_id: str
831831

832832

833+
@dataclass
834+
class NamedRoomMapping(RoomMapping):
835+
"""Dataclass representing a mapping of a room segment to a name.
836+
837+
The name information is not provided by the device directly, but is provided
838+
from the HomeData based on the iot_id from the room.
839+
"""
840+
841+
name: str
842+
"""The human-readable name of the room, if available."""
843+
844+
845+
@dataclass
846+
class CombinedMapInfo(RoborockBase):
847+
"""Data structure for caching home information.
848+
849+
This is not provided directly by the API, but is a combination of map data
850+
and room data to provide a more useful structure.
851+
"""
852+
853+
map_flag: int
854+
"""The map identifier."""
855+
856+
name: str
857+
"""The name of the map from MultiMapsListMapInfo."""
858+
859+
rooms: list[NamedRoomMapping]
860+
"""The list of rooms in the map."""
861+
862+
833863
@dataclass
834864
class ChildLockStatus(RoborockBase):
835865
lock_status: int

roborock/devices/cache.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from dataclasses import dataclass, field
99
from typing import Protocol
1010

11-
from roborock.containers import HomeData, NetworkInfo
11+
from roborock.containers import CombinedMapInfo, HomeData, NetworkInfo
1212

1313

1414
@dataclass
@@ -21,6 +21,9 @@ class CacheData:
2121
network_info: dict[str, NetworkInfo] = field(default_factory=dict)
2222
"""Network information indexed by device DUID."""
2323

24+
home_cache: dict[int, CombinedMapInfo] = field(default_factory=dict)
25+
"""Home cache information indexed by map_flag."""
26+
2427

2528
class Cache(Protocol):
2629
"""Protocol for a cache that can store and retrieve values."""

roborock/devices/traits/v1/home.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""Trait that represents a full view of the home layout.
2+
3+
This trait combines information about maps and rooms to provide a comprehensive
4+
view of the home layout, including room names and their corresponding segment
5+
on the map. It also makes it straight forward to fetch the map image and data.
6+
7+
This trait depends on the MapsTrait and RoomsTrait to gather the necessary
8+
information. It provides properties to access the current map, the list of
9+
rooms with names, and the map image and data.
10+
"""
11+
12+
import asyncio
13+
import logging
14+
from typing import Self
15+
16+
from roborock.containers import CombinedMapInfo, RoborockBase
17+
from roborock.devices.cache import Cache
18+
from roborock.devices.traits.v1 import common
19+
from roborock.exceptions import RoborockException
20+
from roborock.roborock_typing import RoborockCommand
21+
22+
from .maps import MapsTrait
23+
from .rooms import RoomsTrait
24+
25+
_LOGGER = logging.getLogger(__name__)
26+
27+
MAP_SLEEP = 3
28+
29+
30+
class HomeTrait(RoborockBase, common.V1TraitMixin):
31+
"""Trait that represents a full view of the home layout."""
32+
33+
command = RoborockCommand.GET_MAP_V1 # This is not used
34+
35+
def __init__(self, maps_trait: MapsTrait, rooms_trait: RoomsTrait, cache: Cache) -> None:
36+
"""Initialize the HomeTrait.
37+
38+
We keep track of the MapsTrait and RoomsTrait to provide a comprehensive
39+
view of the home layout. This also depends on the StatusTrait to determine
40+
the current map. See comments in MapsTrait for details on that dependency.
41+
42+
The cache is used to store discovered home data to minimize map switching
43+
and improve performance. The cache should be persisted by the caller to
44+
ensure data is retained across restarts.
45+
46+
After initial discovery, only information for the current map is refreshed
47+
to keep data up to date without excessive map switching. However, as
48+
users switch rooms, the current map's data will be updated to ensure
49+
accuracy.
50+
"""
51+
super().__init__()
52+
self._maps_trait = maps_trait
53+
self._rooms_trait = rooms_trait
54+
self._cache = cache
55+
self._home_cache: dict[int, CombinedMapInfo] | None = None
56+
57+
async def discover_home(self) -> None:
58+
"""Iterate through all maps to discover rooms and cache them.
59+
60+
This will be a no-op if the home cache is already populated.
61+
"""
62+
cache_data = await self._cache.get()
63+
if cache_data.home_cache:
64+
_LOGGER.debug("Home cache already populated, skipping discovery")
65+
self._home_cache = cache_data.home_cache
66+
return
67+
68+
await self._maps_trait.refresh()
69+
if self._maps_trait.current_map_info is None:
70+
raise RoborockException("Cannot perform home discovery without current map info")
71+
72+
home_cache = await self._build_home_cache()
73+
cache_data = await self._cache.get()
74+
cache_data.home_cache = home_cache
75+
await self._cache.set(cache_data)
76+
self._home_cache = home_cache
77+
78+
async def _refresh_map_data(self, map_info) -> CombinedMapInfo:
79+
"""Collect room data for a specific map and return CombinedMapInfo."""
80+
await self._rooms_trait.refresh()
81+
return CombinedMapInfo(
82+
map_flag=map_info.map_flag,
83+
name=map_info.name,
84+
rooms=self._rooms_trait.rooms or [],
85+
)
86+
87+
async def _build_home_cache(self) -> dict[int, CombinedMapInfo]:
88+
"""Perform the actual discovery and caching of home data."""
89+
home_cache: dict[int, CombinedMapInfo] = {}
90+
91+
# Sort map_info to process the current map last, reducing map switching.
92+
# False (non-original maps) sorts before True (original map). We ensure
93+
# we load the original map last.
94+
sorted_map_infos = sorted(
95+
self._maps_trait.map_info or [],
96+
key=lambda mi: mi.map_flag == self._maps_trait.current_map,
97+
reverse=False,
98+
)
99+
_LOGGER.debug("Building home cache for maps: %s", [mi.map_flag for mi in sorted_map_infos])
100+
for map_info in sorted_map_infos:
101+
# We need to load each map to get its room data
102+
if len(sorted_map_infos) > 1:
103+
_LOGGER.debug("Loading map %s", map_info.map_flag)
104+
await self._maps_trait.set_current_map(map_info.map_flag)
105+
await asyncio.sleep(MAP_SLEEP)
106+
107+
map_data = await self._refresh_map_data(map_info)
108+
home_cache[map_info.map_flag] = map_data
109+
return home_cache
110+
111+
async def refresh(self) -> Self:
112+
"""Refresh current map's underlying map and room data, updating cache as needed.
113+
114+
This will only refresh the current map's data and will not populate non
115+
active maps or re-discover the home. It is expected that this will keep
116+
information up to date for the current map as users switch to that map.
117+
"""
118+
if self._home_cache is None:
119+
raise RoborockException("Cannot refresh home data without home cache, did you call discover_home()?")
120+
121+
# Refresh the list of map names/info
122+
await self._maps_trait.refresh()
123+
if (current_map_info := self._maps_trait.current_map_info) is None or (
124+
map_flag := self._maps_trait.current_map
125+
) is None:
126+
raise RoborockException("Cannot refresh home data without current map info")
127+
128+
# Refresh the current map's room data
129+
current_map_data = self._home_cache.get(map_flag)
130+
if current_map_data:
131+
map_data = await self._refresh_map_data(current_map_info)
132+
if map_data != current_map_data:
133+
self._home_cache[map_flag] = map_data
134+
# Persist to cache
135+
cache_data = await self._cache.get()
136+
cache_data.home_cache = self._home_cache
137+
await self._cache.set(cache_data)
138+
139+
return self
140+
141+
@property
142+
def home_cache(self) -> dict[int, CombinedMapInfo] | None:
143+
"""Returns the map information for all cached maps."""
144+
return self._home_cache
145+
146+
@property
147+
def current_map_data(self) -> CombinedMapInfo | None:
148+
"""Returns the map data for the current map."""
149+
current_map_flag = self._maps_trait.current_map
150+
if current_map_flag is None or self._home_cache is None:
151+
return None
152+
return self._home_cache.get(current_map_flag)
153+
154+
def _parse_response(self, response: common.V1ResponseData) -> Self:
155+
"""This trait does not parse responses directly."""
156+
raise NotImplementedError("HomeTrait does not support direct command responses")

roborock/devices/traits/v1/maps.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ class MapsTrait(MultiMapsList, common.V1TraitMixin):
2222
2323
A device may have multiple maps, each identified by a unique map_flag.
2424
Each map can have multiple rooms associated with it, in a `RoomMapping`.
25+
26+
The MapsTrait depends on the StatusTrait to determine the currently active
27+
map. It is the responsibility of the caller to ensure that the StatusTrait
28+
is up to date before using this trait. However, there is a possibility of
29+
races if another client changes the current map between the time the
30+
StatusTrait is refreshed and when the MapsTrait is used. This is mitigated
31+
by the fact that the map list is unlikely to change frequently, and the
32+
current map is only changed when the user explicitly switches maps.
2533
"""
2634

2735
command = RoborockCommand.GET_MULTI_MAPS_LIST

roborock/devices/traits/v1/rooms.py

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import logging
44
from dataclasses import dataclass
55

6-
from roborock.containers import HomeData, RoborockBase, RoomMapping
6+
from roborock.containers import HomeData, NamedRoomMapping, RoborockBase
77
from roborock.devices.traits.v1 import common
88
from roborock.roborock_typing import RoborockCommand
99

@@ -12,18 +12,6 @@
1212
_DEFAULT_NAME = "Unknown"
1313

1414

15-
@dataclass
16-
class NamedRoomMapping(RoomMapping):
17-
"""Dataclass representing a mapping of a room segment to a name.
18-
19-
The name information is not provided by the device directly, but is provided
20-
from the HomeData based on the iot_id from the room.
21-
"""
22-
23-
name: str
24-
"""The human-readable name of the room, if available."""
25-
26-
2715
@dataclass
2816
class Rooms(RoborockBase):
2917
"""Dataclass representing a collection of room mappings."""

0 commit comments

Comments
 (0)