Skip to content

Commit a68fbf1

Browse files
authored
feat: Add v1 rooms support to the device traits API (#516)
* feat: Add v1 rooms support to the device traits API * chore: Switch the rooms trait back to the local API * chore: fix typing * fix: fix room mapping parsing bug and add addtiional format samples * chore: add additional example room mapping * fix: update test * chore: abort bad merges * chore: fix lint errors * chore: fix lint errors
1 parent df6c674 commit a68fbf1

File tree

11 files changed

+262
-12
lines changed

11 files changed

+262
-12
lines changed

roborock/cli.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,16 @@ async def reset_consumable(ctx, device_id: str, consumable: str):
475475
click.echo(f"Reset {consumable} for device {device_id}")
476476

477477

478+
@session.command()
479+
@click.option("--device_id", required=True)
480+
@click.pass_context
481+
@async_command
482+
async def rooms(ctx, device_id: str):
483+
"""Get device room mapping info."""
484+
context: RoborockContext = ctx.obj
485+
await _display_v1_trait(context, device_id, lambda v1: v1.rooms)
486+
487+
478488
@click.command()
479489
@click.option("--device_id", required=True)
480490
@click.option("--cmd", required=True)
@@ -719,6 +729,7 @@ def write_markdown_table(product_features: dict[str, dict[str, any]], all_featur
719729
cli.add_command(maps)
720730
cli.add_command(consumables)
721731
cli.add_command(reset_consumable)
732+
cli.add_command(rooms)
722733

723734

724735
def main():

roborock/devices/device_manager.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535

3636

3737
HomeDataApi = Callable[[], Awaitable[HomeData]]
38-
DeviceCreator = Callable[[HomeDataDevice, HomeDataProduct], RoborockDevice]
38+
DeviceCreator = Callable[[HomeData, HomeDataDevice, HomeDataProduct], RoborockDevice]
3939

4040

4141
class DeviceVersion(enum.StrEnum):
@@ -84,7 +84,7 @@ async def discover_devices(self) -> list[RoborockDevice]:
8484
for duid, (device, product) in device_products.items():
8585
if duid in self._devices:
8686
continue
87-
new_device = self._device_creator(device, product)
87+
new_device = self._device_creator(home_data, device, product)
8888
await new_device.connect()
8989
new_devices[duid] = new_device
9090

@@ -143,13 +143,13 @@ async def create_device_manager(
143143
mqtt_params = create_mqtt_params(user_data.rriot)
144144
mqtt_session = await create_lazy_mqtt_session(mqtt_params)
145145

146-
def device_creator(device: HomeDataDevice, product: HomeDataProduct) -> RoborockDevice:
146+
def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDataProduct) -> RoborockDevice:
147147
channel: Channel
148148
trait: Trait
149149
match device.pv:
150150
case DeviceVersion.V1:
151151
channel = create_v1_channel(user_data, mqtt_params, mqtt_session, device, cache)
152-
trait = v1.create(product, channel.rpc_channel, channel.mqtt_rpc_channel)
152+
trait = v1.create(product, home_data, channel.rpc_channel, channel.mqtt_rpc_channel)
153153
case DeviceVersion.A01:
154154
channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
155155
trait = a01.create(product, channel)

roborock/devices/traits/v1/__init__.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from .consumeable import ConsumableTrait
1313
from .do_not_disturb import DoNotDisturbTrait
1414
from .maps import MapsTrait
15+
from .rooms import RoomsTrait
1516
from .status import StatusTrait
1617
from .volume import SoundVolumeTrait
1718

@@ -41,14 +42,18 @@ class PropertiesApi(Trait):
4142
dnd: DoNotDisturbTrait
4243
clean_summary: CleanSummaryTrait
4344
sound_volume: SoundVolumeTrait
45+
rooms: RoomsTrait
4446
maps: MapsTrait
4547
consumables: ConsumableTrait
4648

4749
# In the future optional fields can be added below based on supported features
4850

49-
def __init__(self, product: HomeDataProduct, rpc_channel: V1RpcChannel, mqtt_rpc_channel: V1RpcChannel) -> None:
50-
"""Initialize the V1TraitProps with None values."""
51+
def __init__(
52+
self, product: HomeDataProduct, home_data: HomeData, rpc_channel: V1RpcChannel, mqtt_rpc_channel: V1RpcChannel
53+
) -> None:
54+
"""Initialize the V1TraitProps."""
5155
self.status = StatusTrait(product)
56+
self.rooms = RoomsTrait(home_data)
5257
self.maps = MapsTrait(self.status)
5358

5459
# This is a hack to allow setting the rpc_channel on all traits. This is
@@ -67,6 +72,8 @@ def __init__(self, product: HomeDataProduct, rpc_channel: V1RpcChannel, mqtt_rpc
6772
trait._rpc_channel = rpc_channel
6873

6974

70-
def create(product: HomeDataProduct, rpc_channel: V1RpcChannel, mqtt_rpc_channel: V1RpcChannel) -> PropertiesApi:
75+
def create(
76+
product: HomeDataProduct, home_data: HomeData, rpc_channel: V1RpcChannel, mqtt_rpc_channel: V1RpcChannel
77+
) -> PropertiesApi:
7178
"""Create traits for V1 devices."""
72-
return PropertiesApi(product, rpc_channel, mqtt_rpc_channel)
79+
return PropertiesApi(product, home_data, rpc_channel, mqtt_rpc_channel)

roborock/devices/traits/v1/common.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ class V1TraitMixin(ABC):
3838
command: ClassVar[RoborockCommand]
3939

4040
@classmethod
41-
def _parse_type_response(cls, response: V1ResponseData) -> Self:
41+
def _parse_type_response(cls, response: V1ResponseData) -> RoborockBase:
4242
"""Parse the response from the device into a a RoborockBase.
4343
4444
Subclasses should override this method to implement custom parsing
@@ -53,7 +53,7 @@ def _parse_type_response(cls, response: V1ResponseData) -> Self:
5353
raise ValueError(f"Unexpected {cls} response format: {response!r}")
5454
return cls.from_dict(response)
5555

56-
def _parse_response(self, response: V1ResponseData) -> Self:
56+
def _parse_response(self, response: V1ResponseData) -> RoborockBase:
5757
"""Parse the response from the device into a a RoborockBase.
5858
5959
This is used by subclasses that want to override the class
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Trait for managing room mappings on Roborock devices."""
2+
3+
import logging
4+
from dataclasses import dataclass
5+
6+
from roborock.containers import HomeData, RoborockBase, RoomMapping
7+
from roborock.devices.traits.v1 import common
8+
from roborock.roborock_typing import RoborockCommand
9+
10+
_LOGGER = logging.getLogger(__name__)
11+
12+
_DEFAULT_NAME = "Unknown"
13+
14+
15+
@dataclass
16+
class NamedRoomMapping(RoomMapping):
17+
"""Dataclass representing a mapping of a room segment to a name.
18+
19+
The name information is not provided by the device directly, but is provided
20+
from the HomeData based on the iot_id from the room.
21+
"""
22+
23+
name: str
24+
"""The human-readable name of the room, if available."""
25+
26+
27+
@dataclass
28+
class Rooms(RoborockBase):
29+
"""Dataclass representing a collection of room mappings."""
30+
31+
rooms: list[NamedRoomMapping] | None = None
32+
"""List of room mappings."""
33+
34+
@property
35+
def room_map(self) -> dict[int, NamedRoomMapping]:
36+
"""Returns a mapping of segment_id to NamedRoomMapping."""
37+
if self.rooms is None:
38+
return {}
39+
return {room.segment_id: room for room in self.rooms}
40+
41+
42+
class RoomsTrait(Rooms, common.V1TraitMixin):
43+
"""Trait for managing the room mappings of Roborock devices."""
44+
45+
command = RoborockCommand.GET_ROOM_MAPPING
46+
47+
def __init__(self, home_data: HomeData) -> None:
48+
"""Initialize the RoomsTrait."""
49+
super().__init__()
50+
self._home_data = home_data
51+
52+
@property
53+
def _iot_id_room_name_map(self) -> dict[str, str]:
54+
"""Returns a dictionary of Room IOT IDs to room names."""
55+
return {str(room.id): room.name for room in self._home_data.rooms or ()}
56+
57+
def _parse_response(self, response: common.V1ResponseData) -> Rooms:
58+
"""Parse the response from the device into a list of NamedRoomMapping."""
59+
if not isinstance(response, list):
60+
raise ValueError(f"Unexpected RoomsTrait response format: {response!r}")
61+
name_map = self._iot_id_room_name_map
62+
segment_pairs = _extract_segment_pairs(response)
63+
return Rooms(
64+
rooms=[
65+
NamedRoomMapping(segment_id=segment_id, iot_id=iot_id, name=name_map.get(iot_id, _DEFAULT_NAME))
66+
for segment_id, iot_id in segment_pairs
67+
]
68+
)
69+
70+
71+
def _extract_segment_pairs(response: list) -> list[tuple[int, str]]:
72+
"""Extract segment_id and iot_id pairs from the response.
73+
74+
The response format can be either a flat list of [segment_id, iot_id] or a
75+
list of lists, where each inner list is a pair of [segment_id, iot_id]. This
76+
function normalizes the response into a list of (segment_id, iot_id) tuples
77+
78+
NOTE: We currently only partial samples of the room mapping formats, so
79+
improving test coverage with samples from a real device with this format
80+
would be helpful.
81+
"""
82+
if len(response) == 2 and not isinstance(response[0], list):
83+
segment_id, iot_id = response[0], response[1]
84+
return [(segment_id, iot_id)]
85+
86+
segment_pairs: list[tuple[int, str]] = []
87+
for part in response:
88+
if not isinstance(part, list) or len(part) < 2:
89+
_LOGGER.warning("Unexpected room mapping entry format: %r", part)
90+
continue
91+
segment_id, iot_id = part[0], part[1]
92+
segment_pairs.append((segment_id, iot_id))
93+
return segment_pairs

tests/devices/test_v1_device.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def device_fixture(channel: AsyncMock, rpc_channel: AsyncMock, mqtt_rpc_channel:
4747
return RoborockDevice(
4848
device_info=HOME_DATA.devices[0],
4949
channel=channel,
50-
trait=v1.create(HOME_DATA.products[0], rpc_channel, mqtt_rpc_channel),
50+
trait=v1.create(HOME_DATA.products[0], HOME_DATA, rpc_channel, mqtt_rpc_channel),
5151
)
5252

5353

tests/devices/traits/v1/fixtures.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,5 @@ def device_fixture(channel: AsyncMock, mock_rpc_channel: AsyncMock, mock_mqtt_rp
3939
return RoborockDevice(
4040
device_info=HOME_DATA.devices[0],
4141
channel=channel,
42-
trait=v1.create(HOME_DATA.products[0], mock_rpc_channel, mock_mqtt_rpc_channel),
42+
trait=v1.create(HOME_DATA.products[0], HOME_DATA, mock_rpc_channel, mock_mqtt_rpc_channel),
4343
)
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Tests for the RoomMapping related functionality."""
2+
3+
from typing import Any
4+
from unittest.mock import AsyncMock
5+
6+
import pytest
7+
8+
from roborock.devices.device import RoborockDevice
9+
from roborock.devices.traits.v1.rooms import RoomsTrait
10+
from roborock.devices.traits.v1.status import StatusTrait
11+
from roborock.roborock_typing import RoborockCommand
12+
13+
14+
@pytest.fixture
15+
def status_trait(device: RoborockDevice) -> StatusTrait:
16+
"""Create a StatusTrait instance with mocked dependencies."""
17+
assert device.v1_properties
18+
return device.v1_properties.status
19+
20+
21+
@pytest.fixture
22+
def rooms_trait(device: RoborockDevice) -> RoomsTrait:
23+
"""Create a RoomsTrait instance with mocked dependencies."""
24+
assert device.v1_properties
25+
return device.v1_properties.rooms
26+
27+
28+
# Rooms from mock_data.HOME_DATA
29+
# {"id": 2362048, "name": "Example room 1"},
30+
# {"id": 2362044, "name": "Example room 2"},
31+
# {"id": 2362041, "name": "Example room 3"},
32+
@pytest.mark.parametrize(
33+
("room_mapping_data"),
34+
[
35+
([[16, "2362048"], [17, "2362044"], [18, "2362041"]]),
36+
([[16, "2362048", 6], [17, "2362044", 14], [18, "2362041", 13]]),
37+
],
38+
)
39+
async def test_refresh_rooms_trait(
40+
rooms_trait: RoomsTrait,
41+
mock_rpc_channel: AsyncMock,
42+
room_mapping_data: list[Any],
43+
) -> None:
44+
"""Test successfully getting room mapping."""
45+
# Setup mock to return the sample room mapping
46+
mock_rpc_channel.send_command.side_effect = [room_mapping_data]
47+
# Before refresh, rooms should be empty
48+
assert not rooms_trait.rooms
49+
50+
# Load the room mapping information
51+
refreshed_trait = await rooms_trait.refresh()
52+
53+
# Verify the room mappings are now populated
54+
assert refreshed_trait.rooms
55+
rooms = refreshed_trait.rooms
56+
assert len(rooms) == 3
57+
58+
assert rooms[0].segment_id == 16
59+
assert rooms[0].name == "Example room 1"
60+
assert rooms[0].iot_id == "2362048"
61+
62+
assert rooms[1].segment_id == 17
63+
assert rooms[1].name == "Example room 2"
64+
assert rooms[1].iot_id == "2362044"
65+
66+
assert rooms[2].segment_id == 18
67+
assert rooms[2].name == "Example room 3"
68+
assert rooms[2].iot_id == "2362041"
69+
70+
# Verify the RPC call was made correctly
71+
assert mock_rpc_channel.send_command.call_count == 1
72+
mock_rpc_channel.send_command.assert_any_call(RoborockCommand.GET_ROOM_MAPPING)

tests/protocols/__snapshots__/test_v1_protocol.ambr

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,71 @@
9393
]
9494
'''
9595
# ---
96+
# name: test_decode_rpc_payload[get_room_mapping2]
97+
20001
98+
# ---
99+
# name: test_decode_rpc_payload[get_room_mapping2].1
100+
'''
101+
[
102+
[
103+
16,
104+
"2537178",
105+
6
106+
],
107+
[
108+
17,
109+
"2537175",
110+
14
111+
],
112+
[
113+
18,
114+
"2537174",
115+
13
116+
],
117+
[
118+
19,
119+
"2537176",
120+
14
121+
],
122+
[
123+
20,
124+
"10655627",
125+
12
126+
],
127+
[
128+
21,
129+
"2537145",
130+
2
131+
],
132+
[
133+
22,
134+
"2537147",
135+
12
136+
]
137+
]
138+
'''
139+
# ---
140+
# name: test_decode_rpc_payload[get_room_mapping]
141+
20001
142+
# ---
143+
# name: test_decode_rpc_payload[get_room_mapping].1
144+
'''
145+
[
146+
[
147+
16,
148+
"3031886"
149+
],
150+
[
151+
17,
152+
"3031880"
153+
],
154+
[
155+
18,
156+
"3031883"
157+
]
158+
]
159+
'''
160+
# ---
96161
# name: test_decode_rpc_payload[get_status]
97162
20001
98163
# ---
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"t":1759590351,"dps":{"102":"{\"id\":20001,\"result\":[[16,\"3031886\"],[17,\"3031880\"],[18,\"3031883\"]]}"}}

0 commit comments

Comments
 (0)