Skip to content

Commit d375eb8

Browse files
committed
add shared rooms endpoint
1 parent 2501e26 commit d375eb8

5 files changed

Lines changed: 124 additions & 6 deletions

File tree

roborock/devices/traits/v1/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ def __init__(
199199
self.device_features = DeviceFeaturesTrait(product, self._device_cache)
200200
self.status = StatusTrait(self.device_features, region=self._region)
201201
self.consumables = ConsumableTrait()
202-
self.rooms = RoomsTrait(home_data, web_api)
202+
self.rooms = RoomsTrait(home_data, device_uid, web_api)
203203
self.maps = MapsTrait(self.status)
204204
self.map_content = MapContentTrait(map_parser_config)
205205
self.home = HomeTrait(self.status, self.maps, self.map_content, self.rooms, self._device_cache)

roborock/devices/traits/v1/rooms.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import logging
44
from dataclasses import dataclass
5+
from functools import cached_property
56

67
from roborock.data import HomeData, HomeDataRoom, NamedRoomMapping, RoborockBase
78
from roborock.devices.traits.v1 import common
@@ -84,12 +85,22 @@ class RoomsTrait(Rooms, common.V1TraitMixin):
8485
command = RoborockCommand.GET_ROOM_MAPPING
8586
converter = RoomsConverter()
8687

87-
def __init__(self, home_data: HomeData, web_api: UserWebApiClient) -> None:
88+
def __init__(self, home_data: HomeData, device_uid: str, web_api: UserWebApiClient) -> None:
8889
"""Initialize the RoomsTrait."""
8990
super().__init__()
9091
self._home_data = home_data
92+
self._device_uid = device_uid
9193
self._web_api = web_api
9294
self._discovered_iot_ids: set[str] = set()
95+
self._shared_room_names: dict[str, str] = {}
96+
97+
@cached_property
98+
def _is_shared(self) -> bool:
99+
return any(d.duid == self._device_uid for d in self._home_data.received_devices)
100+
101+
@property
102+
def _room_name_map(self) -> dict[str, str]:
103+
return {**self._home_data.rooms_name_map, **self._shared_room_names}
93104

94105
async def refresh(self) -> None:
95106
"""Refresh room mappings and backfill unknown room names from the web API."""
@@ -104,12 +115,15 @@ async def refresh(self) -> None:
104115

105116
segment_map = RoomsConverter.extract_segment_map(response)
106117
# Track all iot ids seen before. Refresh the room list when new ids are found.
107-
new_iot_ids = set(segment_map.values()) - set(self._home_data.rooms_map.keys())
118+
new_iot_ids = set(segment_map.values()) - set(self._room_name_map.keys())
108119
if new_iot_ids - self._discovered_iot_ids:
109120
_LOGGER.debug("Refreshing room list to discover new room names")
110121
if updated_rooms := await self._refresh_rooms():
111122
_LOGGER.debug("Updating rooms: %s", list(updated_rooms))
112-
self._home_data.rooms = updated_rooms
123+
if self._is_shared:
124+
self._shared_room_names = {room.iot_id: room.name for room in updated_rooms}
125+
else:
126+
self._home_data.rooms = updated_rooms
113127
self._discovered_iot_ids.update(new_iot_ids)
114128
try:
115129
rooms = self.converter.convert(response)
@@ -121,12 +135,14 @@ async def refresh(self) -> None:
121135
inner_error=err,
122136
) from err
123137

124-
rooms = rooms.with_room_names(self._home_data.rooms_name_map)
138+
rooms = rooms.with_room_names(self._room_name_map)
125139
common.merge_trait_values(self, rooms)
126140

127141
async def _refresh_rooms(self) -> list[HomeDataRoom]:
128142
"""Fetch the latest rooms from the web API."""
129143
try:
144+
if self._is_shared:
145+
return await self._web_api.get_shared_device_rooms(self._device_uid)
130146
return await self._web_api.get_rooms()
131147
except Exception:
132148
_LOGGER.debug("Failed to fetch rooms from web API", exc_info=True)

roborock/web_api.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,33 @@ async def get_rooms(self, user_data: UserData, home_id: int | None = None) -> li
557557
else:
558558
raise RoborockException("home_response result was an unexpected type")
559559

560+
async def get_shared_device_rooms(self, user_data: UserData, device_id: str) -> list[HomeDataRoom]:
561+
"""Fetch room names for a shared (received) device."""
562+
rriot = user_data.rriot
563+
if rriot is None:
564+
raise RoborockException("rriot is none")
565+
if rriot.r.a is None:
566+
raise RoborockException("Missing field 'a' in rriot reference")
567+
path = f"/user/deviceshare/query/{device_id}/rooms"
568+
room_request = PreparedRequest(
569+
rriot.r.a,
570+
self.session,
571+
{"Authorization": _get_hawk_authentication(rriot, path)},
572+
)
573+
room_response = await room_request.request("get", path)
574+
if not room_response.get("success"):
575+
raise RoborockException(room_response)
576+
rooms = room_response.get("result")
577+
if isinstance(rooms, list):
578+
output_list = []
579+
for room in rooms:
580+
normalized_room = room
581+
if isinstance(room, dict) and "id" not in room and "roomId" in room:
582+
normalized_room = {**room, "id": room["roomId"]}
583+
output_list.append(HomeDataRoom.from_dict(normalized_room))
584+
return output_list
585+
raise RoborockException("get_shared_device_rooms result was an unexpected type")
586+
560587
async def get_scenes(self, user_data: UserData, device_id: str) -> list[HomeDataScene]:
561588
rriot = user_data.rriot
562589
if rriot is None:
@@ -754,6 +781,10 @@ async def get_rooms(self) -> list[HomeDataRoom]:
754781
"""Fetch rooms using the API client."""
755782
return await self._web_api.get_rooms(self._user_data)
756783

784+
async def get_shared_device_rooms(self, device_id: str) -> list[HomeDataRoom]:
785+
"""Fetch shared-device rooms using the API client."""
786+
return await self._web_api.get_shared_device_rooms(self._user_data, device_id)
787+
757788
async def execute_routine(self, scene_id: int) -> None:
758789
"""Execute a specific routine (scene) by its ID."""
759790
await self._web_api.execute_scene(self._user_data, scene_id)

tests/devices/traits/v1/test_rooms.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,3 +207,34 @@ async def test_refresh_unknown_room_names_failure_falls_back_to_room_segment_id(
207207
assert rooms_trait.rooms[0] == NamedRoomMapping(segment_id=16, iot_id="9999401")
208208
assert rooms_trait.rooms[0].name == "Room 16"
209209
web_api_client.get_rooms.assert_called_once()
210+
211+
212+
async def test_refresh_shared_room_names_use_shared_device_rooms_without_mutating_home_data(
213+
rooms_trait: RoomsTrait,
214+
web_api_client: AsyncMock,
215+
mock_rpc_channel: AsyncMock,
216+
) -> None:
217+
"""Test shared devices resolve room names via the shared-device room list."""
218+
original_rooms = list(rooms_trait._home_data.rooms or ())
219+
try:
220+
# Mark the device as shared by adding it to received_devices
221+
device = next(d for d in rooms_trait._home_data.devices if d.duid == rooms_trait._device_uid)
222+
rooms_trait._home_data.received_devices = [device]
223+
rooms_trait._home_data.devices = []
224+
225+
web_api_client.get_shared_device_rooms.return_value = [
226+
HomeDataRoom(id=9999999, name="Office"),
227+
]
228+
room_mapping_data = [[16, "2362048"], [17, "9999999"]]
229+
mock_rpc_channel.send_command.side_effect = [room_mapping_data]
230+
231+
await rooms_trait.refresh()
232+
233+
assert rooms_trait.rooms
234+
assert rooms_trait.rooms[0] == NamedRoomMapping(segment_id=16, iot_id="2362048", raw_name="Example room 1")
235+
assert rooms_trait.rooms[1] == NamedRoomMapping(segment_id=17, iot_id="9999999", raw_name="Office")
236+
assert rooms_trait._home_data.rooms == original_rooms
237+
web_api_client.get_shared_device_rooms.assert_called_once_with(rooms_trait._device_uid)
238+
web_api_client.get_rooms.assert_not_called()
239+
finally:
240+
rooms_trait._home_data.rooms = original_rooms

tests/test_web_api.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import pytest
66
from aioresponses.compat import normalize_url
77

8-
from roborock import HomeData, HomeDataScene, UserData
8+
from roborock import HomeData, HomeDataRoom, HomeDataScene, UserData
99
from roborock.exceptions import RoborockAccountDoesNotExist
1010
from roborock.web_api import IotLoginInfo, RoborockApiClient
1111
from tests.mock_data import HOME_DATA_RAW, USER_DATA
@@ -374,3 +374,43 @@ async def test_get_schedules(mock_rest) -> None:
374374
assert schedule.cron == "03 13 15 12 ?"
375375
assert schedule.repeated is False
376376
assert schedule.enabled is True
377+
378+
379+
@pytest.mark.parametrize(
380+
"result_payload",
381+
[
382+
# roomId field (deviceshare endpoint convention)
383+
[
384+
{"roomId": "2362048", "name": "Living Room"},
385+
{"roomId": 2362044, "name": "Kitchen"},
386+
],
387+
# id field (matches /user/homes/{id}/rooms — defensive in case the API normalizes)
388+
[
389+
{"id": 2362048, "name": "Living Room"},
390+
{"id": 2362044, "name": "Kitchen"},
391+
],
392+
],
393+
)
394+
async def test_get_shared_device_rooms(mock_rest, result_payload) -> None:
395+
"""Test that shared-device rooms are fetched from the deviceshare query path."""
396+
api = RoborockApiClient(username="test_user@gmail.com")
397+
ud = await api.pass_login("password")
398+
399+
mock_rest.get(
400+
"https://api-us.roborock.com/user/deviceshare/query/device-id-q7/rooms",
401+
status=200,
402+
payload={
403+
"api": None,
404+
"code": 200,
405+
"result": result_payload,
406+
"status": "ok",
407+
"success": True,
408+
},
409+
)
410+
411+
rooms = await api.get_shared_device_rooms(ud, "device-id-q7")
412+
413+
assert rooms == [
414+
HomeDataRoom(id=2362048, name="Living Room"),
415+
HomeDataRoom(id=2362044, name="Kitchen"),
416+
]

0 commit comments

Comments
 (0)