Skip to content

Commit 8ae82d1

Browse files
authored
Add a Home trait that is responsible for caching information about maps and rooms (#526)
* feat: Add a Home trait that for caching information about maps and rooms This will be updated to also handle map content storage in a followup. * chore: Hook up the trait to the device and CLI * chore: Add common routine for updating the cache * fix: Don't perform discovery when the device is cleaning
1 parent ec78beb commit 8ae82d1

File tree

12 files changed

+627
-18
lines changed

12 files changed

+627
-18
lines changed

roborock/cli.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
from pyshark.packet.packet import Packet # type: ignore
4343

4444
from roborock import SHORT_MODEL_TO_ENUM, DeviceFeatures, RoborockCommand, RoborockException
45-
from roborock.containers import DeviceData, HomeData, NetworkInfo, RoborockBase, UserData
45+
from roborock.containers import CombinedMapInfo, DeviceData, HomeData, NetworkInfo, RoborockBase, UserData
4646
from roborock.devices.cache import Cache, CacheData
4747
from roborock.devices.device import RoborockDevice
4848
from roborock.devices.device_manager import DeviceManager, create_device_manager, create_home_data_api
@@ -116,6 +116,7 @@ class ConnectionCache(RoborockBase):
116116
email: str
117117
home_data: HomeData | None = None
118118
network_info: dict[str, NetworkInfo] | None = None
119+
home_cache: dict[int, CombinedMapInfo] | None = None
119120

120121

121122
class DeviceConnectionManager:
@@ -258,14 +259,21 @@ def finish_session(self) -> None:
258259

259260
async def get(self) -> CacheData:
260261
"""Get cached value."""
262+
_LOGGER.debug("Getting cache data")
261263
connection_cache = self.cache_data()
262-
return CacheData(home_data=connection_cache.home_data, network_info=connection_cache.network_info or {})
264+
return CacheData(
265+
home_data=connection_cache.home_data,
266+
network_info=connection_cache.network_info or {},
267+
home_cache=connection_cache.home_cache,
268+
)
263269

264270
async def set(self, value: CacheData) -> None:
265271
"""Set value in the cache."""
272+
_LOGGER.debug("Setting cache data")
266273
connection_cache = self.cache_data()
267274
connection_cache.home_data = value.home_data
268275
connection_cache.network_info = value.network_info
276+
connection_cache.home_cache = value.home_cache
269277
self.update(connection_cache)
270278

271279

@@ -533,6 +541,42 @@ async def rooms(ctx, device_id: str):
533541
await _display_v1_trait(context, device_id, lambda v1: v1.rooms)
534542

535543

544+
@session.command()
545+
@click.option("--device_id", required=True)
546+
@click.option("--refresh", is_flag=True, default=False, help="Refresh status before discovery.")
547+
@click.pass_context
548+
@async_command
549+
async def home(ctx, device_id: str, refresh: bool):
550+
"""Discover and cache home layout (maps and rooms)."""
551+
context: RoborockContext = ctx.obj
552+
device_manager = await context.get_device_manager()
553+
device = await device_manager.get_device(device_id)
554+
if device.v1_properties is None:
555+
raise RoborockException(f"Device {device.name} does not support V1 protocol")
556+
557+
# Ensure we have the latest status before discovery
558+
await device.v1_properties.status.refresh()
559+
560+
home_trait = device.v1_properties.home
561+
await home_trait.discover_home()
562+
if refresh:
563+
await home_trait.refresh()
564+
565+
# Display the discovered home cache
566+
if home_trait.home_cache:
567+
cache_summary = {
568+
map_flag: {
569+
"name": map_data.name,
570+
"room_count": len(map_data.rooms),
571+
"rooms": [{"segment_id": room.segment_id, "name": room.name} for room in map_data.rooms],
572+
}
573+
for map_flag, map_data in home_trait.home_cache.items()
574+
}
575+
click.echo(dump_json(cache_summary))
576+
else:
577+
click.echo("No maps discovered")
578+
579+
536580
@click.command()
537581
@click.option("--device_id", required=True)
538582
@click.option("--cmd", required=True)
@@ -780,6 +824,7 @@ def write_markdown_table(product_features: dict[str, dict[str, any]], all_featur
780824
cli.add_command(consumables)
781825
cli.add_command(reset_consumable)
782826
cli.add_command(rooms)
827+
cli.add_command(home)
783828

784829

785830
def main():

roborock/containers.py

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

881881

882+
@dataclass
883+
class NamedRoomMapping(RoomMapping):
884+
"""Dataclass representing a mapping of a room segment to a name.
885+
886+
The name information is not provided by the device directly, but is provided
887+
from the HomeData based on the iot_id from the room.
888+
"""
889+
890+
name: str
891+
"""The human-readable name of the room, if available."""
892+
893+
894+
@dataclass
895+
class CombinedMapInfo(RoborockBase):
896+
"""Data structure for caching home information.
897+
898+
This is not provided directly by the API, but is a combination of map data
899+
and room data to provide a more useful structure.
900+
"""
901+
902+
map_flag: int
903+
"""The map identifier."""
904+
905+
name: str
906+
"""The name of the map from MultiMapsListMapInfo."""
907+
908+
rooms: list[NamedRoomMapping]
909+
"""The list of rooms in the map."""
910+
911+
882912
@dataclass
883913
class ChildLockStatus(RoborockBase):
884914
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/device_manager.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDat
157157
channel.rpc_channel,
158158
channel.mqtt_rpc_channel,
159159
channel.map_rpc_channel,
160+
cache,
160161
map_parser_config=map_parser_config,
161162
)
162163
case DeviceVersion.A01:

roborock/devices/traits/v1/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from dataclasses import dataclass, field, fields
55

66
from roborock.containers import HomeData, HomeDataProduct
7+
from roborock.devices.cache import Cache
78
from roborock.devices.traits import Trait
89
from roborock.devices.v1_rpc_channel import V1RpcChannel
910
from roborock.map.map_parser import MapParserConfig
@@ -12,6 +13,7 @@
1213
from .common import V1TraitMixin
1314
from .consumeable import ConsumableTrait
1415
from .do_not_disturb import DoNotDisturbTrait
16+
from .home import HomeTrait
1517
from .map_content import MapContentTrait
1618
from .maps import MapsTrait
1719
from .rooms import RoomsTrait
@@ -30,6 +32,7 @@
3032
"MapsTrait",
3133
"MapContentTrait",
3234
"ConsumableTrait",
35+
"HomeTrait",
3336
]
3437

3538

@@ -49,6 +52,7 @@ class PropertiesApi(Trait):
4952
maps: MapsTrait
5053
map_content: MapContentTrait
5154
consumables: ConsumableTrait
55+
home: HomeTrait
5256

5357
# In the future optional fields can be added below based on supported features
5458

@@ -59,13 +63,15 @@ def __init__(
5963
rpc_channel: V1RpcChannel,
6064
mqtt_rpc_channel: V1RpcChannel,
6165
map_rpc_channel: V1RpcChannel,
66+
cache: Cache,
6267
map_parser_config: MapParserConfig | None = None,
6368
) -> None:
6469
"""Initialize the V1TraitProps."""
6570
self.status = StatusTrait(product)
6671
self.rooms = RoomsTrait(home_data)
6772
self.maps = MapsTrait(self.status)
6873
self.map_content = MapContentTrait(map_parser_config)
74+
self.home = HomeTrait(self.status, self.maps, self.rooms, cache)
6975
# This is a hack to allow setting the rpc_channel on all traits. This is
7076
# used so we can preserve the dataclass behavior when the values in the
7177
# traits are updated, but still want to allow them to have a reference
@@ -90,7 +96,8 @@ def create(
9096
rpc_channel: V1RpcChannel,
9197
mqtt_rpc_channel: V1RpcChannel,
9298
map_rpc_channel: V1RpcChannel,
99+
cache: Cache,
93100
map_parser_config: MapParserConfig | None = None,
94101
) -> PropertiesApi:
95102
"""Create traits for V1 devices."""
96-
return PropertiesApi(product, home_data, rpc_channel, mqtt_rpc_channel, map_rpc_channel, map_parser_config)
103+
return PropertiesApi(product, home_data, rpc_channel, mqtt_rpc_channel, map_rpc_channel, cache, map_parser_config)

roborock/devices/traits/v1/home.py

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

0 commit comments

Comments
 (0)