Skip to content

Commit 9a73737

Browse files
committed
feat: Add v1 rooms support to the device traits API
1 parent e9ba1e3 commit 9a73737

File tree

10 files changed

+215
-12
lines changed

10 files changed

+215
-12
lines changed

roborock/cli.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,16 @@ async def maps(ctx, device_id: str):
449449
await _display_v1_trait(context, device_id, lambda v1: v1.maps)
450450

451451

452+
@session.command()
453+
@click.option("--device_id", required=True)
454+
@click.pass_context
455+
@async_command
456+
async def rooms(ctx, device_id: str):
457+
"""Get device room mapping info."""
458+
context: RoborockContext = ctx.obj
459+
await _display_v1_trait(context, device_id, lambda v1: v1.rooms)
460+
461+
452462
@click.command()
453463
@click.option("--device_id", required=True)
454464
@click.option("--cmd", required=True)
@@ -691,6 +701,7 @@ def write_markdown_table(product_features: dict[str, dict[str, any]], all_featur
691701
cli.add_command(volume)
692702
cli.add_command(set_volume)
693703
cli.add_command(maps)
704+
cli.add_command(rooms)
694705

695706

696707
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: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from .common import V1TraitMixin
1212
from .do_not_disturb import DoNotDisturbTrait
1313
from .maps import MapsTrait
14+
from .rooms import RoomsTrait
1415
from .status import StatusTrait
1516
from .volume import SoundVolumeTrait
1617

@@ -24,6 +25,7 @@
2425
"CleanSummaryTrait",
2526
"SoundVolumeTrait",
2627
"MapsTrait",
28+
"RoomsTrait",
2729
]
2830

2931

@@ -39,13 +41,17 @@ class PropertiesApi(Trait):
3941
dnd: DoNotDisturbTrait
4042
clean_summary: CleanSummaryTrait
4143
sound_volume: SoundVolumeTrait
44+
rooms: RoomsTrait
4245
maps: MapsTrait
4346

4447
# In the future optional fields can be added below based on supported features
4548

46-
def __init__(self, product: HomeDataProduct, rpc_channel: V1RpcChannel, mqtt_rpc_channel: V1RpcChannel) -> None:
47-
"""Initialize the V1TraitProps with None values."""
49+
def __init__(
50+
self, product: HomeDataProduct, home_data: HomeData, rpc_channel: V1RpcChannel, mqtt_rpc_channel: V1RpcChannel
51+
) -> None:
52+
"""Initialize the V1TraitProps."""
4853
self.status = StatusTrait(product)
54+
self.rooms = RoomsTrait(home_data)
4955
self.maps = MapsTrait(self.status)
5056

5157
# This is a hack to allow setting the rpc_channel on all traits. This is
@@ -64,6 +70,8 @@ def __init__(self, product: HomeDataProduct, rpc_channel: V1RpcChannel, mqtt_rpc
6470
trait._rpc_channel = rpc_channel
6571

6672

67-
def create(product: HomeDataProduct, rpc_channel: V1RpcChannel, mqtt_rpc_channel: V1RpcChannel) -> PropertiesApi:
73+
def create(
74+
product: HomeDataProduct, home_data: HomeData, rpc_channel: V1RpcChannel, mqtt_rpc_channel: V1RpcChannel
75+
) -> PropertiesApi:
6876
"""Create traits for V1 devices."""
69-
return PropertiesApi(product, rpc_channel, mqtt_rpc_channel)
77+
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: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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 | None = None
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+
@common.mqtt_rpc_channel
43+
class RoomsTrait(Rooms, common.V1TraitMixin):
44+
"""Trait for managing the room mappings of Roborock devices."""
45+
46+
command = RoborockCommand.GET_ROOM_MAPPING
47+
48+
def __init__(self, home_data: HomeData) -> None:
49+
"""Initialize the RoomsTrait."""
50+
super().__init__()
51+
self._home_data = home_data
52+
53+
@property
54+
def _iot_id_room_name_map(self) -> dict[str, str]:
55+
"""Returns a dictionary of Room IOT IDs to room names."""
56+
return {str(room.id): room.name for room in self._home_data.rooms or ()}
57+
58+
def _parse_response(self, response: common.V1ResponseData) -> Rooms:
59+
"""Parse the response from the device into a list of NamedRoomMapping."""
60+
if not isinstance(response, list):
61+
raise ValueError(f"Unexpected RoomsTrait response format: {response!r}")
62+
name_map = self._iot_id_room_name_map
63+
segment_pairs = _extract_segment_pairs(response)
64+
return Rooms(
65+
rooms=[
66+
NamedRoomMapping(segment_id=segment_id, iot_id=iot_id, name=name_map.get(iot_id, _DEFAULT_NAME))
67+
for segment_id, iot_id in segment_pairs
68+
]
69+
)
70+
71+
72+
def _extract_segment_pairs(response: list) -> list[tuple[int, str]]:
73+
"""Extract segment_id and iot_id pairs from the response.
74+
75+
The response format can be either a flat list of [segment_id, iot_id] or a
76+
list of lists, where each inner list is a pair of [segment_id, iot_id]. This
77+
function normalizes the response into a list of (segment_id, iot_id) tuples
78+
79+
NOTE: We currently only have samples of the list of lists format in
80+
tests/protocols/testdata so improving test coverage with samples from a real
81+
device with this format would be helpful.
82+
"""
83+
if len(response) == 2 and not isinstance(response[0], list):
84+
segment_id, iot_id = response[0], response[1]
85+
return [(segment_id, iot_id)]
86+
87+
segment_pairs: list[tuple[int, str]] = []
88+
for part in response:
89+
if not isinstance(part, list) or len(part) != 2:
90+
_LOGGER.warning("Unexpected room mapping entry format: %r", part)
91+
continue
92+
segment_id, iot_id = part[0], part[1]
93+
segment_pairs.append((segment_id, iot_id))
94+
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: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Tests for the RoomMapping related functionality."""
2+
3+
from unittest.mock import AsyncMock
4+
5+
import pytest
6+
7+
from roborock.devices.device import RoborockDevice
8+
from roborock.devices.traits.v1.rooms import RoomsTrait
9+
from roborock.devices.traits.v1.status import StatusTrait
10+
from roborock.roborock_typing import RoborockCommand
11+
12+
# Rooms from mock_data.HOME_DATA
13+
# {"id": 2362048, "name": "Example room 1"},
14+
# {"id": 2362044, "name": "Example room 2"},
15+
# {"id": 2362041, "name": "Example room 3"},
16+
ROOM_MAPPING_DATA = [[16, "2362048"], [17, "2362044"], [18, "2362041"]]
17+
18+
19+
@pytest.fixture
20+
def status_trait(device: RoborockDevice) -> StatusTrait:
21+
"""Create a StatusTrait instance with mocked dependencies."""
22+
assert device.v1_properties
23+
return device.v1_properties.status
24+
25+
26+
@pytest.fixture
27+
def rooms_trait(device: RoborockDevice) -> RoomsTrait:
28+
"""Create a RoomsTrait instance with mocked dependencies."""
29+
assert device.v1_properties
30+
return device.v1_properties.rooms
31+
32+
33+
async def test_refresh_rooms_trait(
34+
rooms_trait: RoomsTrait,
35+
mock_rpc_channel: AsyncMock,
36+
mock_mqtt_rpc_channel: AsyncMock,
37+
) -> None:
38+
"""Test successfully getting room mapping."""
39+
# Setup mock to return the sample room mapping
40+
mock_mqtt_rpc_channel.send_command.side_effect = [
41+
ROOM_MAPPING_DATA,
42+
]
43+
# Before refresh, rooms should be empty
44+
assert not rooms_trait.rooms
45+
46+
# Load the room mapping information
47+
refreshed_trait = await rooms_trait.refresh()
48+
49+
# Verify the room mappings are now populated
50+
assert refreshed_trait.rooms
51+
rooms = refreshed_trait.rooms
52+
assert len(rooms) == 3
53+
54+
assert rooms[0].segment_id == 16
55+
assert rooms[0].name == "Example room 1"
56+
assert rooms[0].iot_id == "2362048"
57+
58+
assert rooms[1].segment_id == 17
59+
assert rooms[1].name == "Example room 2"
60+
assert rooms[1].iot_id == "2362044"
61+
62+
assert rooms[2].segment_id == 18
63+
assert rooms[2].name == "Example room 3"
64+
assert rooms[2].iot_id == "2362041"
65+
66+
# Verify the RPC call was made correctly
67+
assert mock_mqtt_rpc_channel.send_command.call_count == 1
68+
mock_mqtt_rpc_channel.send_command.assert_any_call(RoborockCommand.GET_ROOM_MAPPING)

tests/protocols/__snapshots__/test_v1_protocol.ambr

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,27 @@
7777
]
7878
'''
7979
# ---
80+
# name: test_decode_rpc_payload[get_room_mapping]
81+
20001
82+
# ---
83+
# name: test_decode_rpc_payload[get_room_mapping].1
84+
'''
85+
[
86+
[
87+
16,
88+
"3031886"
89+
],
90+
[
91+
17,
92+
"3031880"
93+
],
94+
[
95+
18,
96+
"3031883"
97+
]
98+
]
99+
'''
100+
# ---
80101
# name: test_decode_rpc_payload[get_status]
81102
20001
82103
# ---
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)