|
| 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") |
0 commit comments