Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion roborock/devices/traits/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ def __init__(
self.device_features = DeviceFeaturesTrait(product, self._device_cache)
self.status = StatusTrait(self.device_features, region=self._region)
self.consumables = ConsumableTrait()
self.rooms = RoomsTrait(home_data)
self.rooms = RoomsTrait(home_data, web_api)
self.maps = MapsTrait(self.status)
self.map_content = MapContentTrait(map_parser_config)
self.home = HomeTrait(self.status, self.maps, self.map_content, self.rooms, self._device_cache)
Expand Down
8 changes: 8 additions & 0 deletions roborock/devices/traits/v1/home.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,14 @@ async def _refresh_map_info(self, map_info) -> CombinedMapInfo:
# or if the room name isn't unknown.
rooms[room.segment_id] = room

for segment_id, room in rooms.items():
if room.name == "Unknown":
Copy link
Copy Markdown
Contributor

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.

rooms[segment_id] = NamedRoomMapping(
segment_id=room.segment_id,
iot_id=room.iot_id,
name=f"Room {room.segment_id}",
)

return CombinedMapInfo(
map_flag=map_info.map_flag,
name=map_info.name,
Expand Down
55 changes: 53 additions & 2 deletions roborock/devices/traits/v1/rooms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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:

  • get the list of present iot ids in the response (and segments)
  • determine if any are missing in the room map
  • fetch the new room map
  • then we merge the room maps (can we just overwrite given we just got the latest rooms rather than merge?)
  • build the namedRoomMapping (again extracting the present iot ids and segments in the response

I think what would simplify things is to make _extract_segment_pairs return a dict instead of a tuple, then this function can do something like this:

segment_map = _extract_segment_map(response)    # Get list of present iot ids  
if (set(segment_map.values()) - set(_iot_id_room_name_map.keys()):  # Check missing
    self._home_data.rooms = await self._refresh_rooms()  # Overwrite, updates _iot_id_room_name_map
new_data = self._parse_response(response, segment_map)   # Build new map
self._update_trait_values(new_data)

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)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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]:
Expand All @@ -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.
Expand Down
8 changes: 6 additions & 2 deletions roborock/web_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -544,10 +544,10 @@ async def get_rooms(self, user_data: UserData, home_id: int | None = None) -> li
rriot.r.a,
self.session,
{
"Authorization": _get_hawk_authentication(rriot, "/v2/user/homes/" + str(home_id)),
"Authorization": _get_hawk_authentication(rriot, f"/user/homes/{home_id}/rooms"),
},
)
room_response = await room_request.request("get", f"/user/homes/{str(home_id)}/rooms" + str(home_id))
room_response = await room_request.request("get", f"/user/homes/{home_id}/rooms")
if not room_response.get("success"):
raise RoborockException(room_response)
rooms = room_response.get("result")
Expand Down Expand Up @@ -752,6 +752,10 @@ async def get_routines(self, device_id: str) -> list[HomeDataScene]:
"""Fetch routines (scenes) for a specific device."""
return await self._web_api.get_scenes(self._user_data, device_id)

async def get_rooms(self) -> list[HomeDataRoom]:
"""Fetch rooms using the API client."""
return await self._web_api.get_rooms(self._user_data)

async def execute_routine(self, scene_id: int) -> None:
"""Execute a specific routine (scene) by its ID."""
await self._web_api.execute_scene(self._user_data, scene_id)
155 changes: 151 additions & 4 deletions tests/devices/traits/v1/test_home.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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()
58 changes: 58 additions & 0 deletions tests/devices/traits/v1/test_rooms.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import pytest

from roborock.data.containers import HomeDataRoom, NamedRoomMapping
from roborock.devices.device import RoborockDevice
from roborock.devices.traits.v1.rooms import RoomsTrait
from roborock.devices.traits.v1.status import StatusTrait
Expand Down Expand Up @@ -70,3 +71,60 @@ async def test_refresh_rooms_trait(
# Verify the RPC call was made correctly
assert mock_rpc_channel.send_command.call_count == 1
mock_rpc_channel.send_command.assert_any_call(RoborockCommand.GET_ROOM_MAPPING)


def test_merge_home_data_rooms_appends_new_rooms(rooms_trait: RoomsTrait) -> None:
"""Test merge_home_data_rooms appends new rooms without replacing known names."""
rooms_trait.merge_home_data_rooms(
[
HomeDataRoom(id=2362048, name="Living Room"),
HomeDataRoom(id=9999999, name="Office"),
]
)

home_data_rooms = {str(room.id): room.name for room in rooms_trait._home_data.rooms}
assert home_data_rooms["2362048"] == "Example room 1"
assert home_data_rooms["9999999"] == "Office"


async def test_refresh_unknown_room_names_web_api_called_once(
rooms_trait: RoomsTrait,
web_api_client: AsyncMock,
mock_rpc_channel: AsyncMock,
) -> None:
"""Test unknown room IDs trigger one web lookup per iot_id."""
web_api_client.get_rooms.return_value = [
HomeDataRoom(id=9999911, name="Living Room"),
]

room_mapping_data = [[16, "9999911"]]
mock_rpc_channel.send_command.side_effect = [room_mapping_data, room_mapping_data]

await rooms_trait.refresh()
assert rooms_trait.rooms
assert rooms_trait.rooms[0].name == "Living Room"

await rooms_trait.refresh()
assert rooms_trait.rooms
assert rooms_trait.rooms[0].name == "Living Room"
web_api_client.get_rooms.assert_called_once()


async def test_refresh_unknown_room_names_unresolved_keeps_unknown(
rooms_trait: RoomsTrait,
web_api_client: AsyncMock,
mock_rpc_channel: AsyncMock,
) -> None:
"""Test unresolved unknown names stay unknown in RoomsTrait."""
web_api_client.get_rooms.return_value = []
room_mapping_data = [[33, "9999922"]]
mock_rpc_channel.send_command.side_effect = [room_mapping_data, room_mapping_data]

await rooms_trait.refresh()
assert rooms_trait.rooms
assert rooms_trait.rooms[0] == NamedRoomMapping(segment_id=33, iot_id="9999922", name="Unknown")

await rooms_trait.refresh()
assert rooms_trait.rooms
assert rooms_trait.rooms[0] == NamedRoomMapping(segment_id=33, iot_id="9999922", name="Unknown")
web_api_client.get_rooms.assert_called_once()