Skip to content

Commit 3ca8043

Browse files
authored
Merge branch 'main' into leo/pr774-split-01-q7-commands
2 parents 88bdb42 + 9d88efa commit 3ca8043

File tree

14 files changed

+405
-86
lines changed

14 files changed

+405
-86
lines changed

CHANGELOG.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,72 @@
22

33
<!-- version list -->
44

5+
## v4.20.0 (2026-03-09)
6+
7+
### Chores
8+
9+
- Update current_rooms to return empty list instead of None
10+
([#781](https://github.com/Python-roborock/python-roborock/pull/781),
11+
[`5853450`](https://github.com/Python-roborock/python-roborock/commit/5853450f182f1b04e65ae553633afc83fbf80c02))
12+
13+
### Features
14+
15+
- Add `current_rooms` property to the `Home` trait and include corresponding tests.
16+
([#781](https://github.com/Python-roborock/python-roborock/pull/781),
17+
[`5853450`](https://github.com/Python-roborock/python-roborock/commit/5853450f182f1b04e65ae553633afc83fbf80c02))
18+
19+
- Allow rooms trait to unconditionally override map info rooms during merging.
20+
([#781](https://github.com/Python-roborock/python-roborock/pull/781),
21+
[`5853450`](https://github.com/Python-roborock/python-roborock/commit/5853450f182f1b04e65ae553633afc83fbf80c02))
22+
23+
- Improve room naming and data integration
24+
([#781](https://github.com/Python-roborock/python-roborock/pull/781),
25+
[`5853450`](https://github.com/Python-roborock/python-roborock/commit/5853450f182f1b04e65ae553633afc83fbf80c02))
26+
27+
- Improve room naming and data integration by introducing `raw_name` to `NamedRoomMapping` and
28+
enhancing `iot_id` and name mapping from `HomeData`.
29+
([#781](https://github.com/Python-roborock/python-roborock/pull/781),
30+
[`5853450`](https://github.com/Python-roborock/python-roborock/commit/5853450f182f1b04e65ae553633afc83fbf80c02))
31+
32+
### Refactoring
33+
34+
- Move NamedRoomMapping import from roborock.data.containers to roborock.data
35+
([#781](https://github.com/Python-roborock/python-roborock/pull/781),
36+
[`5853450`](https://github.com/Python-roborock/python-roborock/commit/5853450f182f1b04e65ae553633afc83fbf80c02))
37+
38+
- Update rooms_map dictionary key type from string to integer
39+
([#781](https://github.com/Python-roborock/python-roborock/pull/781),
40+
[`5853450`](https://github.com/Python-roborock/python-roborock/commit/5853450f182f1b04e65ae553633afc83fbf80c02))
41+
42+
43+
## v4.19.1 (2026-03-09)
44+
45+
### Bug Fixes
46+
47+
- Add missing return value ([#782](https://github.com/Python-roborock/python-roborock/pull/782),
48+
[`3625590`](https://github.com/Python-roborock/python-roborock/commit/36255901f1ade071b97bd116bf2b00c6d152c042))
49+
50+
51+
## v4.19.0 (2026-03-08)
52+
53+
### Bug Fixes
54+
55+
- Make room name always room num and not unknown
56+
([#780](https://github.com/Python-roborock/python-roborock/pull/780),
57+
[`2bd569c`](https://github.com/Python-roborock/python-roborock/commit/2bd569caffe7e85bc08637fd9b8ed70eeade5aa8))
58+
59+
### Chores
60+
61+
- Address comments ([#780](https://github.com/Python-roborock/python-roborock/pull/780),
62+
[`2bd569c`](https://github.com/Python-roborock/python-roborock/commit/2bd569caffe7e85bc08637fd9b8ed70eeade5aa8))
63+
64+
### Features
65+
66+
- Use get_rooms to limit issues with missing room names
67+
([#780](https://github.com/Python-roborock/python-roborock/pull/780),
68+
[`2bd569c`](https://github.com/Python-roborock/python-roborock/commit/2bd569caffe7e85bc08637fd9b8ed70eeade5aa8))
69+
70+
571
## v4.18.1 (2026-03-07)
672

773
### Bug Fixes

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "python-roborock"
3-
version = "4.18.1"
3+
version = "4.20.0"
44
description = "A package to control Roborock vacuums."
55
authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}]
66
requires-python = ">=3.11, <4"

roborock/data/containers.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,11 @@ class HomeDataRoom(RoborockBase):
301301
id: int
302302
name: str
303303

304+
@property
305+
def iot_id(self) -> str:
306+
"""Return the room's ID as a string IOT ID."""
307+
return str(self.id)
308+
304309

305310
@dataclass
306311
class HomeDataScene(RoborockBase):
@@ -352,6 +357,16 @@ def device_products(self) -> dict[str, tuple[HomeDataDevice, HomeDataProduct]]:
352357
if (product := product_map.get(device.product_id)) is not None
353358
}
354359

360+
@property
361+
def rooms_map(self) -> dict[str, HomeDataRoom]:
362+
"""Returns a dictionary of Room iot_id to rooms"""
363+
return {room.iot_id: room for room in self.rooms}
364+
365+
@property
366+
def rooms_name_map(self) -> dict[str, str]:
367+
"""Returns a dictionary of Room iot_id to room names."""
368+
return {room.iot_id: room.name for room in self.rooms}
369+
355370

356371
@dataclass
357372
class LoginData(RoborockBase):
@@ -388,8 +403,13 @@ class NamedRoomMapping(RoomMapping):
388403
from the HomeData based on the iot_id from the room.
389404
"""
390405

391-
name: str
392-
"""The human-readable name of the room, if available."""
406+
@property
407+
def name(self) -> str:
408+
"""The human-readable name of the room, or a default name if not available."""
409+
return self.raw_name or f"Room {self.segment_id}"
410+
411+
raw_name: str | None = None
412+
"""The raw name of the room, as provided by the device."""
393413

394414

395415
@dataclass
@@ -409,6 +429,11 @@ class CombinedMapInfo(RoborockBase):
409429
rooms: list[NamedRoomMapping]
410430
"""The list of rooms in the map."""
411431

432+
@property
433+
def rooms_map(self) -> dict[int, NamedRoomMapping]:
434+
"""Returns a mapping of segment_id to NamedRoomMapping."""
435+
return {room.segment_id: room for room in self.rooms}
436+
412437

413438
@dataclass
414439
class BroadcastMessage(RoborockBase):

roborock/data/v1/v1_containers.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
)
3939
from roborock.exceptions import RoborockException
4040

41-
from ..containers import RoborockBase, RoborockBaseTimer, _attr_repr
41+
from ..containers import NamedRoomMapping, RoborockBase, RoborockBaseTimer, _attr_repr
4242
from .v1_code_mappings import (
4343
CleanFluidStatus,
4444
ClearWaterBoxStatus,
@@ -400,6 +400,7 @@ def clean_fluid_status(self) -> CleanFluidStatus | None:
400400
value = (self.dss >> 10) & 3
401401
if value == 0:
402402
return None # Feature not supported by this device
403+
return CleanFluidStatus(value)
403404
return None
404405

405406
@property
@@ -686,6 +687,17 @@ class MultiMapsListRoom(RoborockBase):
686687
iot_name_id: str | None = None
687688
iot_name: str | None = None
688689

690+
@property
691+
def named_room_mapping(self) -> NamedRoomMapping | None:
692+
"""Returns a NamedRoomMapping object if valid."""
693+
if self.id is None or self.iot_name_id is None:
694+
return None
695+
return NamedRoomMapping(
696+
segment_id=self.id,
697+
iot_id=self.iot_name_id,
698+
raw_name=self.iot_name,
699+
)
700+
689701

690702
@dataclass
691703
class MultiMapsListMapInfoBakMaps(RoborockBase):
@@ -707,6 +719,15 @@ def mapFlag(self) -> int:
707719
"""Alias for map_flag, returns the map flag as an integer."""
708720
return self.map_flag
709721

722+
@property
723+
def rooms_map(self) -> dict[int, NamedRoomMapping]:
724+
"""Returns a dictionary of room mappings by segment id."""
725+
return {
726+
room.id: mapping
727+
for room in self.rooms or ()
728+
if room.id is not None and (mapping := room.named_room_mapping) is not None
729+
}
730+
710731

711732
@dataclass
712733
class MultiMapsList(RoborockBase):

roborock/devices/traits/v1/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ def __init__(
193193
self.device_features = DeviceFeaturesTrait(product, self._device_cache)
194194
self.status = StatusTrait(self.device_features, region=self._region)
195195
self.consumables = ConsumableTrait()
196-
self.rooms = RoomsTrait(home_data)
196+
self.rooms = RoomsTrait(home_data, web_api)
197197
self.maps = MapsTrait(self.status)
198198
self.map_content = MapContentTrait(map_parser_config)
199199
self.home = HomeTrait(self.status, self.maps, self.map_content, self.rooms, self._device_cache)

roborock/devices/traits/v1/home.py

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import logging
2121
from typing import Self
2222

23-
from roborock.data import CombinedMapInfo, NamedRoomMapping, RoborockBase
23+
from roborock.data import CombinedMapInfo, MultiMapsListMapInfo, NamedRoomMapping, RoborockBase
2424
from roborock.data.v1.v1_code_mappings import RoborockStateCode
2525
from roborock.devices.cache import DeviceCache
2626
from roborock.devices.traits.v1 import common
@@ -114,34 +114,24 @@ async def discover_home(self) -> None:
114114
self._discovery_completed = True
115115
await self._update_home_cache(home_map_info, home_map_content)
116116

117-
async def _refresh_map_info(self, map_info) -> CombinedMapInfo:
117+
async def _refresh_map_info(self, map_info: MultiMapsListMapInfo) -> CombinedMapInfo:
118118
"""Collect room data for a specific map and return CombinedMapInfo."""
119119
await self._rooms_trait.refresh()
120120

121-
rooms: dict[int, NamedRoomMapping] = {}
122-
if map_info.rooms:
123-
# Not all vacuums resopnd with rooms inside map_info.
124-
for room in map_info.rooms:
125-
if room.id is not None and room.iot_name_id is not None:
126-
rooms[room.id] = NamedRoomMapping(
127-
segment_id=room.id,
128-
iot_id=room.iot_name_id,
129-
name=room.iot_name or "Unknown",
130-
)
131-
132-
# Add rooms from rooms_trait. If room already exists and rooms_trait has "Unknown", don't override.
133-
if self._rooms_trait.rooms:
134-
for room in self._rooms_trait.rooms:
135-
if room.segment_id is not None and room.name:
136-
if room.segment_id not in rooms or room.name != "Unknown":
137-
# Add the room to rooms if the room segment is not already in it
138-
# or if the room name isn't unknown.
139-
rooms[room.segment_id] = room
140-
121+
# We have room names from multiple sources:
122+
# - The map_info.rooms which we just received from the MultiMapsList
123+
# - RoomsTrait rooms come from the GET_ROOM_MAPPING command for the current device (only)
124+
# - RoomsTrait rooms that are pulled from the cloud API
125+
# We always prefer the RoomsTrait room names since they are always newer and
126+
# just refreshed above.
127+
rooms_map: dict[int, NamedRoomMapping] = {
128+
**map_info.rooms_map,
129+
**{room.segment_id: room for room in self._rooms_trait.rooms or ()},
130+
}
141131
return CombinedMapInfo(
142132
map_flag=map_info.map_flag,
143133
name=map_info.name,
144-
rooms=list(rooms.values()),
134+
rooms=list(rooms_map.values()),
145135
)
146136

147137
async def _refresh_map_content(self) -> MapContent:
@@ -231,6 +221,13 @@ def current_map_data(self) -> CombinedMapInfo | None:
231221
return None
232222
return self._home_map_info.get(current_map_flag)
233223

224+
@property
225+
def current_rooms(self) -> list[NamedRoomMapping]:
226+
"""Returns the room names for the current map."""
227+
if self.current_map_data is None:
228+
return []
229+
return self.current_map_data.rooms
230+
234231
@property
235232
def home_map_content(self) -> dict[int, MapContent] | None:
236233
"""Returns the map content for all cached maps."""

roborock/devices/traits/v1/rooms.py

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,13 @@
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
9+
from roborock.web_api import UserWebApiClient
910

1011
_LOGGER = logging.getLogger(__name__)
1112

12-
_DEFAULT_NAME = "Unknown"
13-
1413

1514
@dataclass
1615
class Rooms(RoborockBase):
@@ -32,50 +31,75 @@ class RoomsTrait(Rooms, common.V1TraitMixin):
3231

3332
command = RoborockCommand.GET_ROOM_MAPPING
3433

35-
def __init__(self, home_data: HomeData) -> None:
34+
def __init__(self, home_data: HomeData, web_api: UserWebApiClient) -> None:
3635
"""Initialize the RoomsTrait."""
3736
super().__init__()
3837
self._home_data = home_data
38+
self._web_api = web_api
39+
self._discovered_iot_ids: set[str] = set()
3940

40-
@property
41-
def _iot_id_room_name_map(self) -> dict[str, str]:
42-
"""Returns a dictionary of Room IOT IDs to room names."""
43-
return {str(room.id): room.name for room in self._home_data.rooms or ()}
44-
45-
def _parse_response(self, response: common.V1ResponseData) -> Rooms:
46-
"""Parse the response from the device into a list of NamedRoomMapping."""
41+
async def refresh(self) -> None:
42+
"""Refresh room mappings and backfill unknown room names from the web API."""
43+
response = await self.rpc_channel.send_command(self.command)
4744
if not isinstance(response, list):
4845
raise ValueError(f"Unexpected RoomsTrait response format: {response!r}")
49-
name_map = self._iot_id_room_name_map
50-
segment_pairs = _extract_segment_pairs(response)
46+
47+
segment_map = _extract_segment_map(response)
48+
# Track all iot ids seen before. Refresh the room list when new ids are found.
49+
new_iot_ids = set(segment_map.values()) - set(self._home_data.rooms_map.keys())
50+
if new_iot_ids - self._discovered_iot_ids:
51+
_LOGGER.debug("Refreshing room list to discover new room names")
52+
if updated_rooms := await self._refresh_rooms():
53+
_LOGGER.debug("Updating rooms: %s", list(updated_rooms))
54+
self._home_data.rooms = updated_rooms
55+
self._discovered_iot_ids.update(new_iot_ids)
56+
57+
new_data = self._parse_rooms(segment_map, self._home_data.rooms_name_map)
58+
self._update_trait_values(new_data)
59+
_LOGGER.debug("Refreshed %s: %s", self.__class__.__name__, new_data)
60+
61+
@staticmethod
62+
def _parse_rooms(
63+
segment_map: dict[int, str],
64+
name_map: dict[str, str],
65+
) -> Rooms:
66+
"""Parse the response from the device into a list of NamedRoomMapping."""
5167
return Rooms(
5268
rooms=[
53-
NamedRoomMapping(segment_id=segment_id, iot_id=iot_id, name=name_map.get(iot_id, _DEFAULT_NAME))
54-
for segment_id, iot_id in segment_pairs
69+
NamedRoomMapping(segment_id=segment_id, iot_id=iot_id, raw_name=name_map.get(iot_id))
70+
for segment_id, iot_id in segment_map.items()
5571
]
5672
)
5773

74+
async def _refresh_rooms(self) -> list[HomeDataRoom]:
75+
"""Fetch the latest rooms from the web API."""
76+
try:
77+
return await self._web_api.get_rooms()
78+
except Exception:
79+
_LOGGER.debug("Failed to fetch rooms from web API", exc_info=True)
80+
return []
81+
5882

59-
def _extract_segment_pairs(response: list) -> list[tuple[int, str]]:
60-
"""Extract segment_id and iot_id pairs from the response.
83+
def _extract_segment_map(response: list) -> dict[int, str]:
84+
"""Extract a segment_id -> iot_id mapping from the response.
6185
6286
The response format can be either a flat list of [segment_id, iot_id] or a
6387
list of lists, where each inner list is a pair of [segment_id, iot_id]. This
64-
function normalizes the response into a list of (segment_id, iot_id) tuples
88+
function normalizes the response into a dict of segment_id to iot_id.
6589
6690
NOTE: We currently only partial samples of the room mapping formats, so
6791
improving test coverage with samples from a real device with this format
6892
would be helpful.
6993
"""
7094
if len(response) == 2 and not isinstance(response[0], list):
7195
segment_id, iot_id = response[0], response[1]
72-
return [(segment_id, iot_id)]
96+
return {segment_id: str(iot_id)}
7397

74-
segment_pairs: list[tuple[int, str]] = []
98+
segment_map: dict[int, str] = {}
7599
for part in response:
76100
if not isinstance(part, list) or len(part) < 2:
77101
_LOGGER.warning("Unexpected room mapping entry format: %r", part)
78102
continue
79103
segment_id, iot_id = part[0], part[1]
80-
segment_pairs.append((segment_id, iot_id))
81-
return segment_pairs
104+
segment_map[segment_id] = str(iot_id)
105+
return segment_map

0 commit comments

Comments
 (0)