Skip to content

Commit 543ada0

Browse files
committed
feat: use get_rooms to limit issues with missing room names
1 parent 2ec5897 commit 543ada0

File tree

5 files changed

+242
-9
lines changed

5 files changed

+242
-9
lines changed

roborock/devices/traits/v1/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ def __init__(
196196
self.rooms = RoomsTrait(home_data)
197197
self.maps = MapsTrait(self.status)
198198
self.map_content = MapContentTrait(map_parser_config)
199-
self.home = HomeTrait(self.status, self.maps, self.map_content, self.rooms, self._device_cache)
199+
self.home = HomeTrait(self.status, self.maps, self.map_content, self.rooms, self._device_cache, web_api)
200200
self.network_info = NetworkInfoTrait(device_uid, self._device_cache)
201201
self.routines = RoutinesTrait(device_uid, web_api)
202202

roborock/devices/traits/v1/home.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from roborock.devices.traits.v1 import common
2727
from roborock.exceptions import RoborockDeviceBusy, RoborockException, RoborockInvalidStatus
2828
from roborock.roborock_typing import RoborockCommand
29+
from roborock.web_api import UserWebApiClient
2930

3031
from .map_content import MapContent, MapContentTrait
3132
from .maps import MapsTrait
@@ -49,6 +50,7 @@ def __init__(
4950
map_content: MapContentTrait,
5051
rooms_trait: RoomsTrait,
5152
device_cache: DeviceCache,
53+
web_api: UserWebApiClient | None = None,
5254
) -> None:
5355
"""Initialize the HomeTrait.
5456
@@ -71,6 +73,8 @@ def __init__(
7173
self._map_content = map_content
7274
self._rooms_trait = rooms_trait
7375
self._device_cache = device_cache
76+
self._web_api = web_api
77+
self._seen_room_iot_ids_by_map: dict[int, set[str]] = {}
7478
self._discovery_completed = False
7579
self._home_map_info: dict[int, CombinedMapInfo] | None = None
7680
self._home_map_content: dict[int, MapContent] | None = None
@@ -90,6 +94,7 @@ async def discover_home(self) -> None:
9094
if device_cache_data and device_cache_data.home_map_info:
9195
_LOGGER.debug("Home cache already populated, skipping discovery")
9296
self._home_map_info = device_cache_data.home_map_info
97+
self._seed_seen_room_iot_ids(device_cache_data.home_map_info)
9398
self._discovery_completed = True
9499
try:
95100
self._home_map_content = {
@@ -138,6 +143,40 @@ async def _refresh_map_info(self, map_info) -> CombinedMapInfo:
138143
# or if the room name isn't unknown.
139144
rooms[room.segment_id] = room
140145

146+
# If we discovered at least one new unknown room iot_id for this map, fetch names from web API.
147+
seen_room_iot_ids = self._seen_room_iot_ids_by_map.setdefault(map_info.map_flag, set())
148+
has_new_unknown_room_iot_id = any(
149+
room.name == "Unknown" and room.iot_id not in seen_room_iot_ids for room in rooms.values()
150+
)
151+
if self._web_api and has_new_unknown_room_iot_id:
152+
try:
153+
web_rooms = await self._web_api.get_rooms()
154+
web_room_names = {str(r.id): r.name for r in web_rooms}
155+
for segment_id, room in rooms.items():
156+
if room.name == "Unknown" and room.iot_id in web_room_names:
157+
rooms[segment_id] = NamedRoomMapping(
158+
segment_id=room.segment_id,
159+
iot_id=room.iot_id,
160+
name=web_room_names[room.iot_id],
161+
)
162+
# Merge new rooms into home_data so future refreshes benefit
163+
if web_rooms:
164+
self._rooms_trait.merge_home_data_rooms(web_rooms)
165+
except Exception:
166+
# Broad exception as we don't want anything here to make us fail upwards, we are okay with 'unknowns'
167+
_LOGGER.debug("Failed to fetch rooms from web API for map %s", map_info.map_flag)
168+
169+
# Replace remaining "Unknown" names with "Room {room_id}" fallback
170+
for segment_id, room in rooms.items():
171+
if room.name == "Unknown":
172+
rooms[segment_id] = NamedRoomMapping(
173+
segment_id=room.segment_id,
174+
iot_id=room.iot_id,
175+
name=f"Room {room.segment_id}",
176+
)
177+
178+
self._seen_room_iot_ids_by_map[map_info.map_flag].update(room.iot_id for room in rooms.values())
179+
141180
return CombinedMapInfo(
142181
map_flag=map_info.map_flag,
143182
name=map_info.name,
@@ -254,6 +293,7 @@ async def _update_home_cache(
254293
await self._device_cache.set(device_cache_data)
255294
self._home_map_info = home_map_info
256295
self._home_map_content = home_map_content
296+
self._seed_seen_room_iot_ids(home_map_info)
257297

258298
async def _update_current_map(
259299
self,
@@ -282,7 +322,13 @@ async def _update_current_map(
282322
if self._home_map_info is None:
283323
self._home_map_info = {}
284324
self._home_map_info[map_flag] = map_info
325+
self._seed_seen_room_iot_ids({map_flag: map_info})
285326

286327
if self._home_map_content is None:
287328
self._home_map_content = {}
288329
self._home_map_content[map_flag] = map_content
330+
331+
def _seed_seen_room_iot_ids(self, home_map_info: dict[int, CombinedMapInfo]) -> None:
332+
"""Seed known room iot_ids from cached/current map information."""
333+
for map_flag, map_info in home_map_info.items():
334+
self._seen_room_iot_ids_by_map.setdefault(map_flag, set()).update(room.iot_id for room in map_info.rooms)

roborock/devices/traits/v1/rooms.py

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

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

@@ -55,6 +55,16 @@ def _parse_response(self, response: common.V1ResponseData) -> Rooms:
5555
]
5656
)
5757

58+
def merge_home_data_rooms(self, rooms: list[HomeDataRoom]) -> None:
59+
"""Merge newly discovered rooms into home data by room id."""
60+
existing_ids = {room.id for room in self._home_data.rooms or ()}
61+
updated_rooms = list(self._home_data.rooms or ())
62+
for room in rooms:
63+
if room.id not in existing_ids:
64+
updated_rooms.append(room)
65+
existing_ids.add(room.id)
66+
self._home_data.rooms = updated_rooms
67+
5868

5969
def _extract_segment_pairs(response: list) -> list[tuple[int, str]]:
6070
"""Extract segment_id and iot_id pairs from the response.

roborock/web_api.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -544,10 +544,10 @@ async def get_rooms(self, user_data: UserData, home_id: int | None = None) -> li
544544
rriot.r.a,
545545
self.session,
546546
{
547-
"Authorization": _get_hawk_authentication(rriot, "/v2/user/homes/" + str(home_id)),
547+
"Authorization": _get_hawk_authentication(rriot, f"/user/homes/{home_id}/rooms"),
548548
},
549549
)
550-
room_response = await room_request.request("get", f"/user/homes/{str(home_id)}/rooms" + str(home_id))
550+
room_response = await room_request.request("get", f"/user/homes/{home_id}/rooms")
551551
if not room_response.get("success"):
552552
raise RoborockException(room_response)
553553
rooms = room_response.get("result")
@@ -752,6 +752,10 @@ async def get_routines(self, device_id: str) -> list[HomeDataScene]:
752752
"""Fetch routines (scenes) for a specific device."""
753753
return await self._web_api.get_scenes(self._user_data, device_id)
754754

755+
async def get_rooms(self) -> list[HomeDataRoom]:
756+
"""Fetch rooms using the API client."""
757+
return await self._web_api.get_rooms(self._user_data)
758+
755759
async def execute_routine(self, scene_id: int) -> None:
756760
"""Execute a specific routine (scene) by its ID."""
757761
await self._web_api.execute_scene(self._user_data, scene_id)

tests/devices/traits/v1/test_home.py

Lines changed: 178 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import pytest
88

9-
from roborock.data.containers import CombinedMapInfo, NamedRoomMapping
9+
from roborock.data.containers import CombinedMapInfo, HomeDataRoom, NamedRoomMapping
1010
from roborock.data.v1.v1_code_mappings import RoborockStateCode
1111
from roborock.data.v1.v1_containers import MultiMapsListMapInfo, MultiMapsListRoom
1212
from roborock.devices.cache import DeviceCache, DeviceCacheData, InMemoryCache
@@ -139,16 +139,25 @@ def rooms_trait(device: RoborockDevice) -> RoomsTrait:
139139
return device.v1_properties.rooms
140140

141141

142+
@pytest.fixture
143+
def mock_web_api() -> AsyncMock:
144+
"""Create a mock web API client."""
145+
mock = AsyncMock()
146+
mock.get_rooms.return_value = []
147+
return mock
148+
149+
142150
@pytest.fixture
143151
def home_trait(
144152
status_trait: StatusTrait,
145153
maps_trait: MapsTrait,
146154
map_content_trait: MapContentTrait,
147155
rooms_trait: RoomsTrait,
148156
device_cache: DeviceCache,
157+
mock_web_api: AsyncMock,
149158
) -> HomeTrait:
150159
"""Create a HomeTrait instance with mocked dependencies."""
151-
return HomeTrait(status_trait, maps_trait, map_content_trait, rooms_trait, device_cache)
160+
return HomeTrait(status_trait, maps_trait, map_content_trait, rooms_trait, device_cache, mock_web_api)
152161

153162

154163
@pytest.fixture(autouse=True)
@@ -227,7 +236,7 @@ async def test_discover_home_empty_cache(
227236
assert map_123_data.rooms[0].segment_id == 18
228237
assert map_123_data.rooms[0].name == "Example room 3"
229238
assert map_123_data.rooms[1].segment_id == 19
230-
assert map_123_data.rooms[1].name == "Unknown" # Not in mock home data
239+
assert map_123_data.rooms[1].name == "Map 123" # Not in mock home data
231240

232241
map_123_content = home_trait.home_map_content[123]
233242
assert map_123_content is not None
@@ -620,6 +629,7 @@ async def test_single_map_no_switching(
620629
async def test_refresh_map_info_room_override_and_addition_logic(
621630
home_trait: HomeTrait,
622631
rooms_trait: RoomsTrait,
632+
mock_web_api: AsyncMock,
623633
) -> None:
624634
"""Test the room override and addition logic in _refresh_map_info.
625635
@@ -660,6 +670,9 @@ async def test_refresh_map_info_room_override_and_addition_logic(
660670
NamedRoomMapping(segment_id=18, iot_id="2362041", name="Example room 3"), # Not in map_info, should be added
661671
]
662672

673+
# Mock web API to return empty rooms (no resolution)
674+
mock_web_api.get_rooms.return_value = []
675+
663676
# Mock rooms_trait.refresh to prevent actual device calls
664677
with patch.object(rooms_trait, "refresh", new_callable=AsyncMock):
665678
result = await home_trait._refresh_map_info(map_info)
@@ -676,9 +689,9 @@ async def test_refresh_map_info_room_override_and_addition_logic(
676689
assert sorted_rooms[0].name == "Kitchen from map_info"
677690
assert sorted_rooms[0].iot_id == "2362048"
678691

679-
# Room 17: from rooms_trait with "Unknown", added because not in map_info
692+
# Room 17: from rooms_trait with "Unknown", falls back to "Map 0"
680693
assert sorted_rooms[1].segment_id == 17
681-
assert sorted_rooms[1].name == "Unknown"
694+
assert sorted_rooms[1].name == "Map 0"
682695
assert sorted_rooms[1].iot_id == "2362044"
683696

684697
# Room 18: from rooms_trait with valid name, added because not in map_info
@@ -690,3 +703,163 @@ async def test_refresh_map_info_room_override_and_addition_logic(
690703
assert sorted_rooms[3].segment_id == 19
691704
assert sorted_rooms[3].name == "Updated Bedroom Name"
692705
assert sorted_rooms[3].iot_id == "2362042"
706+
707+
708+
async def test_get_rooms_resolves_unknown_rooms(
709+
home_trait: HomeTrait,
710+
rooms_trait: RoomsTrait,
711+
mock_web_api: AsyncMock,
712+
) -> None:
713+
"""Test that get_rooms from web API resolves unknown room names."""
714+
map_info = MultiMapsListMapInfo(
715+
map_flag=0,
716+
name="Test Map",
717+
rooms=[
718+
MultiMapsListRoom(id=16, iot_name_id="2362048", iot_name=None),
719+
MultiMapsListRoom(id=17, iot_name_id="2362044", iot_name=None),
720+
],
721+
)
722+
723+
rooms_trait.rooms = [
724+
NamedRoomMapping(segment_id=16, iot_id="2362048", name="Unknown"),
725+
NamedRoomMapping(segment_id=17, iot_id="2362044", name="Unknown"),
726+
]
727+
728+
# Web API returns fresh room names
729+
mock_web_api.get_rooms.return_value = [
730+
HomeDataRoom(id=2362048, name="Living Room"),
731+
HomeDataRoom(id=2362044, name="Kitchen"),
732+
]
733+
734+
with patch.object(rooms_trait, "refresh", new_callable=AsyncMock):
735+
result = await home_trait._refresh_map_info(map_info)
736+
737+
sorted_rooms = sorted(result.rooms, key=lambda r: r.segment_id)
738+
assert sorted_rooms[0].name == "Living Room"
739+
assert sorted_rooms[1].name == "Kitchen"
740+
mock_web_api.get_rooms.assert_called_once()
741+
742+
743+
async def test_get_rooms_called_once_for_same_unknown_room(
744+
home_trait: HomeTrait,
745+
rooms_trait: RoomsTrait,
746+
mock_web_api: AsyncMock,
747+
) -> None:
748+
"""Test that get_rooms is not re-called for an already-seen unknown room."""
749+
map_info = MultiMapsListMapInfo(
750+
map_flag=42,
751+
name="Test Map",
752+
rooms=[
753+
MultiMapsListRoom(id=16, iot_name_id="2362048", iot_name=None),
754+
],
755+
)
756+
757+
rooms_trait.rooms = [
758+
NamedRoomMapping(segment_id=16, iot_id="2362048", name="Unknown"),
759+
]
760+
761+
# Web API returns empty (no resolution)
762+
mock_web_api.get_rooms.return_value = []
763+
764+
with patch.object(rooms_trait, "refresh", new_callable=AsyncMock):
765+
result1 = await home_trait._refresh_map_info(map_info)
766+
result2 = await home_trait._refresh_map_info(map_info)
767+
768+
# Both calls should fall back to "Map 42"
769+
assert result1.rooms[0].name == "Map 42"
770+
assert result2.rooms[0].name == "Map 42"
771+
# get_rooms should only be called once for the same unknown room
772+
mock_web_api.get_rooms.assert_called_once()
773+
774+
775+
async def test_get_rooms_called_again_for_new_unknown_room(
776+
home_trait: HomeTrait,
777+
rooms_trait: RoomsTrait,
778+
mock_web_api: AsyncMock,
779+
) -> None:
780+
"""Test that get_rooms is called again when a new unknown room appears."""
781+
map_info = MultiMapsListMapInfo(
782+
map_flag=42,
783+
name="Test Map",
784+
rooms=[
785+
MultiMapsListRoom(id=16, iot_name_id="2362048", iot_name=None),
786+
],
787+
)
788+
789+
rooms_trait.rooms = [
790+
NamedRoomMapping(segment_id=16, iot_id="2362048", name="Unknown"),
791+
]
792+
793+
# Web API returns empty (no resolution)
794+
mock_web_api.get_rooms.return_value = []
795+
796+
with patch.object(rooms_trait, "refresh", new_callable=AsyncMock):
797+
result1 = await home_trait._refresh_map_info(map_info)
798+
799+
# Add a brand-new unknown room for the same map
800+
rooms_trait.rooms = [
801+
NamedRoomMapping(segment_id=16, iot_id="2362048", name="Unknown"),
802+
NamedRoomMapping(segment_id=17, iot_id="2362044", name="Unknown"),
803+
]
804+
result2 = await home_trait._refresh_map_info(map_info)
805+
806+
assert sorted(room.name for room in result1.rooms) == ["Map 42"]
807+
assert sorted(room.name for room in result2.rooms) == ["Map 42", "Map 42"]
808+
assert mock_web_api.get_rooms.call_count == 2
809+
810+
811+
async def test_get_rooms_called_again_for_new_unknown_iot_id_same_segment(
812+
home_trait: HomeTrait,
813+
rooms_trait: RoomsTrait,
814+
mock_web_api: AsyncMock,
815+
) -> None:
816+
"""Test that get_rooms is called again for a new unknown iot_id on the same segment."""
817+
map_info = MultiMapsListMapInfo(
818+
map_flag=42,
819+
name="Test Map",
820+
)
821+
822+
rooms_trait.rooms = [
823+
NamedRoomMapping(segment_id=16, iot_id="2362048", name="Unknown"),
824+
]
825+
826+
mock_web_api.get_rooms.return_value = []
827+
828+
with patch.object(rooms_trait, "refresh", new_callable=AsyncMock):
829+
await home_trait._refresh_map_info(map_info)
830+
831+
# Same segment, but now with a different iot_id.
832+
rooms_trait.rooms = [
833+
NamedRoomMapping(segment_id=16, iot_id="2362999", name="Unknown"),
834+
]
835+
await home_trait._refresh_map_info(map_info)
836+
837+
assert mock_web_api.get_rooms.call_count == 2
838+
839+
840+
async def test_get_rooms_failure_falls_back_to_map_flag(
841+
home_trait: HomeTrait,
842+
rooms_trait: RoomsTrait,
843+
mock_web_api: AsyncMock,
844+
) -> None:
845+
"""Test that get_rooms failure gracefully falls back to 'Map {flag}'."""
846+
map_info = MultiMapsListMapInfo(
847+
map_flag=7,
848+
name="Test Map",
849+
rooms=[
850+
MultiMapsListRoom(id=16, iot_name_id="2362048", iot_name=None),
851+
],
852+
)
853+
854+
rooms_trait.rooms = [
855+
NamedRoomMapping(segment_id=16, iot_id="2362048", name="Unknown"),
856+
]
857+
858+
# Web API raises an exception
859+
mock_web_api.get_rooms.side_effect = Exception("API error")
860+
861+
with patch.object(rooms_trait, "refresh", new_callable=AsyncMock):
862+
result = await home_trait._refresh_map_info(map_info)
863+
864+
assert result.rooms[0].name == "Map 7"
865+
mock_web_api.get_rooms.assert_called_once()

0 commit comments

Comments
 (0)