-
Notifications
You must be signed in to change notification settings - Fork 75
feat: use get_rooms to limit issues with missing room names #780
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
543ada0
8b80865
000b510
d11a52c
75a6f49
329f545
952d2e2
84f85e4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,9 +3,10 @@ | |
| import logging | ||
| from dataclasses import dataclass | ||
|
|
||
| from roborock.data import HomeData, NamedRoomMapping, RoborockBase | ||
| from roborock.data import HomeData, HomeDataRoom, NamedRoomMapping, RoborockBase | ||
| from roborock.devices.traits.v1 import common | ||
| from roborock.roborock_typing import RoborockCommand | ||
| from roborock.web_api import UserWebApiClient | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
|
|
@@ -32,10 +33,25 @@ class RoomsTrait(Rooms, common.V1TraitMixin): | |
|
|
||
| command = RoborockCommand.GET_ROOM_MAPPING | ||
|
|
||
| def __init__(self, home_data: HomeData) -> None: | ||
| def __init__(self, home_data: HomeData, web_api: UserWebApiClient) -> None: | ||
| """Initialize the RoomsTrait.""" | ||
| super().__init__() | ||
| self._home_data = home_data | ||
| self._web_api = web_api | ||
| self._seen_unknown_room_iot_ids: set[str] = set() | ||
|
|
||
| async def refresh(self) -> None: | ||
| """Refresh room mappings and backfill unknown room names from the web API.""" | ||
| response = await self.rpc_channel.send_command(self.command) | ||
| if not isinstance(response, list): | ||
| raise ValueError(f"Unexpected RoomsTrait response format: {response!r}") | ||
|
|
||
| segment_pairs = _extract_segment_pairs(response) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OK now that its all in one file, there is a lot of back and forth between these methods, and mutating of state, multiple checks for the default name, and a few of these methods and properties called a few different times. I think he flow is:
I think what would simplify things is to make Would a flow like that work? I think it would have all the side effects visible here. (This sill has a hidden interaction with _iot_id_room_name_map inside parse_response but maybe ok)
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think that makes sense from my understanding of the apis now. I keep a 'Unknown' check even though i don't think it will hit as I'm not 100% sure how the cache will handle things. Check it out let me know your thoughts, appreciate you being thorough on this, hopefully we can get it right and stop having this bug. |
||
| await self._populate_missing_home_data_rooms(segment_pairs) | ||
|
|
||
| new_data = self._parse_response(response) | ||
| self._update_trait_values(new_data) | ||
| _LOGGER.debug("Refreshed %s: %s", self.__class__.__name__, new_data) | ||
|
|
||
| @property | ||
| def _iot_id_room_name_map(self) -> dict[str, str]: | ||
|
|
@@ -55,6 +71,41 @@ def _parse_response(self, response: common.V1ResponseData) -> Rooms: | |
| ] | ||
| ) | ||
|
|
||
| def merge_home_data_rooms(self, rooms: list[HomeDataRoom]) -> None: | ||
| """Merge newly discovered rooms into home data by room id.""" | ||
| updated_rooms = list(self._home_data.rooms or ()) | ||
| existing_by_id = {room.id: room for room in updated_rooms} | ||
|
|
||
| for room in rooms: | ||
| existing_room = existing_by_id.get(room.id) | ||
| if existing_room is None: | ||
| updated_rooms.append(room) | ||
| existing_by_id[room.id] = room | ||
| elif room.name and existing_room.name in ("", _DEFAULT_NAME): | ||
| existing_room.name = room.name | ||
|
|
||
| self._home_data.rooms = updated_rooms | ||
|
|
||
| async def _populate_missing_home_data_rooms(self, segment_pairs: list[tuple[int, str]]) -> None: | ||
| """Load missing room names into home data for newly-seen unknown room ids.""" | ||
| name_map = self._iot_id_room_name_map | ||
| unknown_room_iot_ids = { | ||
| iot_id for _, iot_id in segment_pairs if name_map.get(iot_id, _DEFAULT_NAME) == _DEFAULT_NAME | ||
| } | ||
| new_unknown_room_iot_ids = unknown_room_iot_ids - self._seen_unknown_room_iot_ids | ||
| if not new_unknown_room_iot_ids: | ||
| return | ||
|
|
||
| try: | ||
| web_rooms = await self._web_api.get_rooms() | ||
| except Exception: | ||
| _LOGGER.debug("Failed to fetch rooms from web API", exc_info=True) | ||
| else: | ||
| if web_rooms: | ||
| self.merge_home_data_rooms(web_rooms) | ||
|
|
||
| self._seen_unknown_room_iot_ids.update(unknown_room_iot_ids) | ||
|
|
||
|
|
||
| def _extract_segment_pairs(response: list) -> list[tuple[int, str]]: | ||
| """Extract segment_id and iot_id pairs from the response. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,7 +6,7 @@ | |
|
|
||
| import pytest | ||
|
|
||
| from roborock.data.containers import CombinedMapInfo, NamedRoomMapping | ||
| from roborock.data.containers import CombinedMapInfo, HomeDataRoom, NamedRoomMapping | ||
| from roborock.data.v1.v1_code_mappings import RoborockStateCode | ||
| from roborock.data.v1.v1_containers import MultiMapsListMapInfo, MultiMapsListRoom | ||
| from roborock.devices.cache import DeviceCache, DeviceCacheData, InMemoryCache | ||
|
|
@@ -139,6 +139,13 @@ def rooms_trait(device: RoborockDevice) -> RoomsTrait: | |
| return device.v1_properties.rooms | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def mock_web_api(web_api_client: AsyncMock) -> AsyncMock: | ||
| """Alias the shared web API fixture for readability in this module.""" | ||
| web_api_client.get_rooms.return_value = [] | ||
| return web_api_client | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def home_trait( | ||
| status_trait: StatusTrait, | ||
|
|
@@ -227,7 +234,7 @@ async def test_discover_home_empty_cache( | |
| assert map_123_data.rooms[0].segment_id == 18 | ||
| assert map_123_data.rooms[0].name == "Example room 3" | ||
| assert map_123_data.rooms[1].segment_id == 19 | ||
| assert map_123_data.rooms[1].name == "Unknown" # Not in mock home data | ||
| assert map_123_data.rooms[1].name == "Room 19" # Not in mock home data | ||
|
|
||
| map_123_content = home_trait.home_map_content[123] | ||
| assert map_123_content is not None | ||
|
|
@@ -676,9 +683,9 @@ async def test_refresh_map_info_room_override_and_addition_logic( | |
| assert sorted_rooms[0].name == "Kitchen from map_info" | ||
| assert sorted_rooms[0].iot_id == "2362048" | ||
|
|
||
| # Room 17: from rooms_trait with "Unknown", added because not in map_info | ||
| # Room 17: from rooms_trait with "Unknown", falls back to "Room 17" | ||
| assert sorted_rooms[1].segment_id == 17 | ||
| assert sorted_rooms[1].name == "Unknown" | ||
| assert sorted_rooms[1].name == "Room 17" | ||
| assert sorted_rooms[1].iot_id == "2362044" | ||
|
|
||
| # Room 18: from rooms_trait with valid name, added because not in map_info | ||
|
|
@@ -690,3 +697,143 @@ async def test_refresh_map_info_room_override_and_addition_logic( | |
| assert sorted_rooms[3].segment_id == 19 | ||
| assert sorted_rooms[3].name == "Updated Bedroom Name" | ||
| assert sorted_rooms[3].iot_id == "2362042" | ||
|
|
||
|
|
||
| async def test_get_rooms_resolves_unknown_rooms( | ||
| home_trait: HomeTrait, | ||
| mock_rpc_channel: AsyncMock, | ||
| mock_web_api: AsyncMock, | ||
| ) -> None: | ||
| """Test that get_rooms from web API resolves unknown room names.""" | ||
| room_mapping_data = [[16, "9999801"], [17, "9999802"]] | ||
| mock_rpc_channel.send_command.side_effect = [room_mapping_data] | ||
|
|
||
| map_info = MultiMapsListMapInfo( | ||
| map_flag=0, | ||
| name="Test Map", | ||
| rooms=[ | ||
| MultiMapsListRoom(id=16, iot_name_id="9999801", iot_name=None), | ||
| MultiMapsListRoom(id=17, iot_name_id="9999802", iot_name=None), | ||
| ], | ||
| ) | ||
|
|
||
| # Web API returns fresh room names | ||
| mock_web_api.get_rooms.return_value = [ | ||
| HomeDataRoom(id=9999801, name="Living Room"), | ||
| HomeDataRoom(id=9999802, name="Kitchen"), | ||
| ] | ||
|
|
||
| result = await home_trait._refresh_map_info(map_info) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Either move to room trait tests or use public home apis? |
||
|
|
||
| sorted_rooms = sorted(result.rooms, key=lambda r: r.segment_id) | ||
| assert sorted_rooms[0].name == "Living Room" | ||
| assert sorted_rooms[1].name == "Kitchen" | ||
| mock_web_api.get_rooms.assert_called_once() | ||
|
|
||
|
|
||
| async def test_get_rooms_called_once_for_same_unknown_room( | ||
| home_trait: HomeTrait, | ||
| mock_rpc_channel: AsyncMock, | ||
| mock_web_api: AsyncMock, | ||
| ) -> None: | ||
| """Test that get_rooms is not re-called for an already-seen unknown room.""" | ||
| room_mapping_data = [[16, "9999701"]] | ||
| mock_rpc_channel.send_command.side_effect = [room_mapping_data, room_mapping_data] | ||
|
|
||
| map_info = MultiMapsListMapInfo( | ||
| map_flag=42, | ||
| name="Test Map", | ||
| rooms=[ | ||
| MultiMapsListRoom(id=16, iot_name_id="9999701", iot_name=None), | ||
| ], | ||
| ) | ||
|
|
||
| # Web API returns empty (no resolution) | ||
| mock_web_api.get_rooms.return_value = [] | ||
|
|
||
| result1 = await home_trait._refresh_map_info(map_info) | ||
| result2 = await home_trait._refresh_map_info(map_info) | ||
|
|
||
| # Both calls should fall back to "Room 16" | ||
| assert result1.rooms[0].name == "Room 16" | ||
| assert result2.rooms[0].name == "Room 16" | ||
| # get_rooms should only be called once for the same unknown room | ||
| mock_web_api.get_rooms.assert_called_once() | ||
|
|
||
|
|
||
| async def test_get_rooms_called_again_for_new_unknown_room( | ||
| home_trait: HomeTrait, | ||
| mock_rpc_channel: AsyncMock, | ||
| mock_web_api: AsyncMock, | ||
| ) -> None: | ||
| """Test that get_rooms is called again when a new unknown room appears.""" | ||
| room_mapping_data_1 = [[16, "9999601"]] | ||
| room_mapping_data_2 = [[16, "9999601"], [17, "9999602"]] | ||
| mock_rpc_channel.send_command.side_effect = [room_mapping_data_1, room_mapping_data_2] | ||
|
|
||
| map_info = MultiMapsListMapInfo( | ||
| map_flag=42, | ||
| name="Test Map", | ||
| rooms=[ | ||
| MultiMapsListRoom(id=16, iot_name_id="9999601", iot_name=None), | ||
| ], | ||
| ) | ||
|
|
||
| # Web API returns empty (no resolution) | ||
| mock_web_api.get_rooms.return_value = [] | ||
|
|
||
| result1 = await home_trait._refresh_map_info(map_info) | ||
| result2 = await home_trait._refresh_map_info(map_info) | ||
|
|
||
| assert sorted(room.name for room in result1.rooms) == ["Room 16"] | ||
| assert sorted(room.name for room in result2.rooms) == ["Room 16", "Room 17"] | ||
| assert mock_web_api.get_rooms.call_count == 2 | ||
|
|
||
|
|
||
| async def test_get_rooms_called_again_for_new_unknown_iot_id_same_segment( | ||
| home_trait: HomeTrait, | ||
| mock_rpc_channel: AsyncMock, | ||
| mock_web_api: AsyncMock, | ||
| ) -> None: | ||
| """Test that get_rooms is called again for a new unknown iot_id on the same segment.""" | ||
| room_mapping_data_1 = [[16, "9999501"]] | ||
| room_mapping_data_2 = [[16, "9999502"]] | ||
| mock_rpc_channel.send_command.side_effect = [room_mapping_data_1, room_mapping_data_2] | ||
|
|
||
| map_info = MultiMapsListMapInfo( | ||
| map_flag=42, | ||
| name="Test Map", | ||
| ) | ||
|
|
||
| mock_web_api.get_rooms.return_value = [] | ||
|
|
||
| await home_trait._refresh_map_info(map_info) | ||
| await home_trait._refresh_map_info(map_info) | ||
|
|
||
| assert mock_web_api.get_rooms.call_count == 2 | ||
|
|
||
|
|
||
| async def test_get_rooms_failure_falls_back_to_room_segment_id( | ||
| home_trait: HomeTrait, | ||
| mock_rpc_channel: AsyncMock, | ||
| mock_web_api: AsyncMock, | ||
| ) -> None: | ||
| """Test that get_rooms failure gracefully falls back to 'Room {segment_id}'.""" | ||
| room_mapping_data = [[16, "9999401"]] | ||
| mock_rpc_channel.send_command.side_effect = [room_mapping_data] | ||
|
|
||
| map_info = MultiMapsListMapInfo( | ||
| map_flag=7, | ||
| name="Test Map", | ||
| rooms=[ | ||
| MultiMapsListRoom(id=16, iot_name_id="9999401", iot_name=None), | ||
| ], | ||
| ) | ||
|
|
||
| # Web API raises an exception | ||
| mock_web_api.get_rooms.side_effect = Exception("API error") | ||
|
|
||
| result = await home_trait._refresh_map_info(map_info) | ||
|
|
||
| assert result.rooms[0].name == "Room 16" | ||
| mock_web_api.get_rooms.assert_called_once() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can RoomsTrait also handle this? It seems like it has enough information to be able to create the rooms with the correct names from the start.