Skip to content

Commit a793c8e

Browse files
authored
feat: implement dynamic map update listeners and filtering on HomeTrait (#861)
This change enables Home Assistant to dynamically add and remove maps/camera entities without restarting. Resolves HA issue: home-assistant/core#175400
1 parent b679d55 commit a793c8e

2 files changed

Lines changed: 171 additions & 3 deletions

File tree

roborock/devices/traits/v1/home.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@
1818
import asyncio
1919
import base64
2020
import logging
21+
from collections.abc import Callable
2122

2223
from roborock.data import CombinedMapInfo, MultiMapsListMapInfo, NamedRoomMapping, RoborockBase
2324
from roborock.data.v1.v1_code_mappings import RoborockStateCode
2425
from roborock.devices.cache import DeviceCache
26+
from roborock.devices.traits.common import TraitUpdateListener
2527
from roborock.devices.traits.v1 import common
2628
from roborock.exceptions import RoborockDeviceBusy, RoborockException, RoborockInvalidStatus
2729
from roborock.roborock_typing import RoborockCommand
@@ -36,7 +38,7 @@
3638
MAP_SLEEP = 3
3739

3840

39-
class HomeTrait(RoborockBase, common.V1TraitMixin):
41+
class HomeTrait(RoborockBase, common.V1TraitMixin, TraitUpdateListener):
4042
"""Trait that represents a full view of the home layout."""
4143

4244
command = RoborockCommand.GET_MAP_V1 # This is not used
@@ -66,6 +68,7 @@ def __init__(
6668
accuracy.
6769
"""
6870
super().__init__()
71+
TraitUpdateListener.__init__(self, logger=_LOGGER)
6972
self._status_trait = status_trait
7073
self._maps_trait = maps_trait
7174
self._map_content = map_content
@@ -100,6 +103,7 @@ async def discover_home(self) -> None:
100103
_LOGGER.warning("Failed to parse cached home map content, will re-discover: %s", ex)
101104
self._home_map_content = {}
102105
else:
106+
self._notify_update()
103107
return
104108

105109
if self._status_trait.state == RoborockStateCode.cleaning:
@@ -202,6 +206,7 @@ async def refresh(self) -> None:
202206
map_flag := self._maps_trait.current_map
203207
) is None:
204208
_LOGGER.debug("Cannot refresh home data without current map info")
209+
self._notify_update()
205210
return
206211

207212
# Refresh the map content to ensure we have the latest image and object positions
@@ -212,10 +217,26 @@ async def refresh(self) -> None:
212217
map_flag, combined_map_info, new_map_content, update_cache=self._discovery_completed
213218
)
214219

220+
def add_update_listener(self, callback: Callable[[], None]) -> Callable[[], None]:
221+
"""Register a callback when the trait has been updated.
222+
223+
Overridden to immediately execute the callback with the current state if populated.
224+
"""
225+
unsub = super().add_update_listener(callback)
226+
if self._home_map_info is not None:
227+
callback()
228+
return unsub
229+
215230
@property
216231
def home_map_info(self) -> dict[int, CombinedMapInfo] | None:
217232
"""Returns the map information for all cached maps."""
218-
return self._home_map_info
233+
if self._home_map_info is None or self._maps_trait.map_info is None:
234+
return self._home_map_info
235+
return {
236+
mi.map_flag: value
237+
for mi in self._maps_trait.map_info
238+
if (value := self._home_map_info.get(mi.map_flag)) is not None
239+
}
219240

220241
@property
221242
def current_map_data(self) -> CombinedMapInfo | None:
@@ -235,7 +256,13 @@ def current_rooms(self) -> list[NamedRoomMapping]:
235256
@property
236257
def home_map_content(self) -> dict[int, MapContent] | None:
237258
"""Returns the map content for all cached maps."""
238-
return self._home_map_content
259+
if self._home_map_content is None or self._maps_trait.map_info is None:
260+
return self._home_map_content
261+
return {
262+
mi.map_flag: value
263+
for mi in self._maps_trait.map_info
264+
if (value := self._home_map_content.get(mi.map_flag)) is not None
265+
}
239266

240267
async def _update_home_cache(
241268
self, home_map_info: dict[int, CombinedMapInfo], home_map_content: dict[int, MapContent]
@@ -251,6 +278,7 @@ async def _update_home_cache(
251278
await self._device_cache.set(device_cache_data)
252279
self._home_map_info = home_map_info
253280
self._home_map_content = home_map_content
281+
self._notify_update()
254282

255283
async def _update_current_map(
256284
self,
@@ -283,3 +311,4 @@ async def _update_current_map(
283311
if self._home_map_content is None:
284312
self._home_map_content = {}
285313
self._home_map_content[map_flag] = map_content
314+
self._notify_update()

tests/devices/traits/v1/test_home.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,3 +748,142 @@ async def test_refresh_map_info_prefers_map_info_names_and_adds_missing_rooms(
748748
assert sorted_rooms[4].segment_id == 20
749749
assert sorted_rooms[4].name == "Office from rooms_trait"
750750
assert sorted_rooms[4].iot_id == "9999001"
751+
752+
753+
async def test_home_trait_listener_notifications(
754+
home_trait: HomeTrait,
755+
mock_rpc_channel: AsyncMock,
756+
mock_mqtt_rpc_channel: AsyncMock,
757+
mock_map_rpc_channel: AsyncMock,
758+
device_cache: DeviceCache,
759+
web_api_client: AsyncMock,
760+
) -> None:
761+
"""Test that listener callbacks are called when home discovery/updates occur."""
762+
mock_callback = MagicMock()
763+
unsub = home_trait.add_update_listener(mock_callback)
764+
# Callback should NOT be executed immediately on subscription since home_map_info is None
765+
assert mock_callback.call_count == 0
766+
767+
# 1. Test notification on empty cache discovery
768+
mock_rpc_channel.send_command.side_effect = [
769+
UPDATED_STATUS_MAP_123,
770+
ROOM_MAPPING_DATA_MAP_123,
771+
UPDATED_STATUS_MAP_0,
772+
ROOM_MAPPING_DATA_MAP_0,
773+
]
774+
mock_mqtt_rpc_channel.send_command.side_effect = [
775+
MULTI_MAP_LIST_DATA,
776+
{},
777+
{},
778+
]
779+
mock_map_rpc_channel.send_command.side_effect = [
780+
MAP_BYTES_RESPONSE_2,
781+
MAP_BYTES_RESPONSE_1,
782+
]
783+
web_api_client.get_rooms.return_value = [
784+
HomeDataRoom(id=2362048, name="Example room 1"),
785+
HomeDataRoom(id=2362044, name="Example room 2"),
786+
]
787+
788+
await home_trait.discover_home()
789+
# Mock callback should have been called during discovery
790+
assert mock_callback.call_count > 0
791+
mock_callback.reset_mock()
792+
793+
# 2. Test that registering a new listener now (when cache is populated) executes immediately
794+
mock_callback_immediate = MagicMock()
795+
unsub_immediate = home_trait.add_update_listener(mock_callback_immediate)
796+
assert mock_callback_immediate.call_count == 1
797+
unsub_immediate()
798+
799+
# 3. Test notification on cached discovery
800+
# Re-run discover_home (which skips API calls and loads from cache)
801+
await home_trait.discover_home()
802+
assert mock_callback.call_count == 1
803+
mock_callback.reset_mock()
804+
805+
# 4. Test notification on refresh
806+
mock_rpc_channel.send_command.side_effect = [
807+
ROOM_MAPPING_DATA_MAP_0,
808+
]
809+
mock_mqtt_rpc_channel.send_command.side_effect = [
810+
MULTI_MAP_LIST_DATA,
811+
]
812+
mock_map_rpc_channel.send_command.side_effect = [
813+
MAP_BYTES_RESPONSE_1,
814+
]
815+
await home_trait.refresh()
816+
assert mock_callback.call_count > 0
817+
818+
# Unsubscribe and verify it is no longer called
819+
mock_callback.reset_mock()
820+
unsub()
821+
mock_rpc_channel.send_command.side_effect = [
822+
ROOM_MAPPING_DATA_MAP_0,
823+
]
824+
mock_mqtt_rpc_channel.send_command.side_effect = [
825+
MULTI_MAP_LIST_DATA,
826+
]
827+
mock_map_rpc_channel.send_command.side_effect = [
828+
MAP_BYTES_RESPONSE_1,
829+
]
830+
await home_trait.refresh()
831+
assert mock_callback.call_count == 0
832+
833+
834+
async def test_home_trait_map_eviction(
835+
home_trait: HomeTrait,
836+
mock_rpc_channel: AsyncMock,
837+
mock_mqtt_rpc_channel: AsyncMock,
838+
mock_map_rpc_channel: AsyncMock,
839+
device_cache: DeviceCache,
840+
web_api_client: AsyncMock,
841+
) -> None:
842+
"""Test that maps deleted from the device are evicted from cache during refresh."""
843+
# Pre-populate cache with maps 0 and 123
844+
device_cache_data = DeviceCacheData(
845+
home_map_info={
846+
0: CombinedMapInfo(map_flag=0, name="Ground Floor", rooms=[]),
847+
123: CombinedMapInfo(map_flag=123, name="Second Floor", rooms=[]),
848+
},
849+
home_map_content_base64={
850+
0: base64.b64encode(MAP_BYTES_RESPONSE_1).decode("utf-8"),
851+
123: base64.b64encode(MAP_BYTES_RESPONSE_2).decode("utf-8"),
852+
},
853+
)
854+
await device_cache.set(device_cache_data)
855+
await home_trait.discover_home()
856+
857+
assert home_trait.home_map_info is not None
858+
assert home_trait.home_map_content is not None
859+
assert len(home_trait.home_map_info) == 2
860+
assert len(home_trait.home_map_content) == 2
861+
862+
# Set up listener callback
863+
mock_callback = MagicMock()
864+
home_trait.add_update_listener(mock_callback)
865+
assert mock_callback.call_count == 1
866+
mock_callback.reset_mock()
867+
868+
# Mock maps_trait.refresh so that only map 0 is returned (map 123 deleted)
869+
mock_mqtt_rpc_channel.send_command.side_effect = [
870+
MULTI_MAP_LIST_SINGLE_MAP_DATA, # Only has map 0
871+
]
872+
mock_rpc_channel.send_command.side_effect = [
873+
ROOM_MAPPING_DATA_MAP_0,
874+
]
875+
mock_map_rpc_channel.send_command.side_effect = [
876+
MAP_BYTES_RESPONSE_1,
877+
]
878+
879+
await home_trait.refresh()
880+
881+
# Verify map 123 is excluded from memory cache
882+
assert home_trait.home_map_info is not None
883+
assert home_trait.home_map_content is not None
884+
assert 123 not in home_trait.home_map_info
885+
assert 123 not in home_trait.home_map_content
886+
assert 0 in home_trait.home_map_info
887+
888+
# Verify listener notified
889+
assert mock_callback.call_count > 0

0 commit comments

Comments
 (0)