Skip to content

Commit 0fbeca3

Browse files
committed
feat: add v1 api support for the list of maps
1 parent c0c082b commit 0fbeca3

File tree

7 files changed

+238
-5
lines changed

7 files changed

+238
-5
lines changed

roborock/cli.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,16 @@ async def set_volume(ctx, device_id: str, volume: int):
439439
click.echo(f"Set Device {device_id} volume to {volume}")
440440

441441

442+
@session.command()
443+
@click.option("--device_id", required=True)
444+
@click.pass_context
445+
@async_command
446+
async def maps(ctx, device_id: str):
447+
"""Get device maps info."""
448+
context: RoborockContext = ctx.obj
449+
await _display_v1_trait(context, device_id, lambda v1: v1.maps)
450+
451+
442452
@click.command()
443453
@click.option("--device_id", required=True)
444454
@click.option("--cmd", required=True)
@@ -680,6 +690,7 @@ def write_markdown_table(product_features: dict[str, dict[str, any]], all_featur
680690
cli.add_command(clean_summary)
681691
cli.add_command(volume)
682692
cli.add_command(set_volume)
693+
cli.add_command(maps)
683694

684695

685696
def main():

roborock/devices/traits/v1/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22

33
from dataclasses import dataclass, field, fields
44

5-
from roborock.containers import HomeDataProduct
5+
from roborock.containers import HomeData, HomeDataProduct
66
from roborock.devices.traits import Trait
77
from roborock.devices.v1_rpc_channel import V1RpcChannel
88

99
from .clean_summary import CleanSummaryTrait
1010
from .common import V1TraitMixin
1111
from .do_not_disturb import DoNotDisturbTrait
12+
from .maps import MapsTrait
1213
from .status import StatusTrait
1314
from .volume import SoundVolumeTrait
1415

@@ -19,6 +20,7 @@
1920
"DoNotDisturbTrait",
2021
"CleanSummaryTrait",
2122
"SoundVolumeTrait",
23+
"MapsTrait",
2224
]
2325

2426

@@ -34,12 +36,14 @@ class PropertiesApi(Trait):
3436
dnd: DoNotDisturbTrait
3537
clean_summary: CleanSummaryTrait
3638
sound_volume: SoundVolumeTrait
39+
maps: MapsTrait
3740

3841
# In the future optional fields can be added below based on supported features
3942

4043
def __init__(self, product: HomeDataProduct, rpc_channel: V1RpcChannel) -> None:
4144
"""Initialize the V1TraitProps with None values."""
4245
self.status = StatusTrait(product)
46+
self.maps = MapsTrait(self.status)
4347

4448
# This is a hack to allow setting the rpc_channel on all traits. This is
4549
# used so we can preserve the dataclass behavior when the values in the

roborock/devices/traits/v1/common.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"""
55

66
from abc import ABC
7-
from dataclasses import asdict, dataclass, fields
7+
from dataclasses import dataclass, fields
88
from typing import ClassVar, Self
99

1010
from roborock.containers import RoborockBase
@@ -77,9 +77,11 @@ async def refresh(self) -> Self:
7777
"""Refresh the contents of this trait."""
7878
response = await self.rpc_channel.send_command(self.command)
7979
new_data = self._parse_response(response)
80-
for k, v in asdict(new_data).items():
81-
if v is not None:
82-
setattr(self, k, v)
80+
if not isinstance(new_data, RoborockBase):
81+
raise ValueError(f"Internal error, unexpected response type: {new_data!r}")
82+
for field in fields(new_data):
83+
new_value = getattr(new_data, field.name, None)
84+
setattr(self, field.name, new_value)
8385
return self
8486

8587

roborock/devices/traits/v1/maps.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Trait for managing maps and room mappings on Roborock devices.
2+
3+
New datatypes are introduced here to manage the additional information associated
4+
with maps and rooms, such as map names and room names. These override the
5+
base container datatypes to add additional fields.
6+
"""
7+
import logging
8+
from typing import Self
9+
10+
from roborock.containers import MultiMapsList, MultiMapsListMapInfo
11+
from roborock.devices.traits.v1 import common
12+
from roborock.roborock_typing import RoborockCommand
13+
14+
from .status import StatusTrait
15+
16+
_LOGGER = logging.getLogger(__name__)
17+
18+
19+
class MapsTrait(MultiMapsList, common.V1TraitMixin):
20+
"""Trait for managing the maps of Roborock devices.
21+
22+
A device may have multiple maps, each identified by a unique map_flag.
23+
Each map can have multiple rooms associated with it, in a `RoomMapping`.
24+
"""
25+
26+
command = RoborockCommand.GET_MULTI_MAPS_LIST
27+
28+
def __init__(self, status_trait: StatusTrait) -> None:
29+
"""Initialize the MapsTrait.
30+
31+
We keep track of the StatusTrait to ensure we have the latest
32+
status information when dealing with maps.
33+
"""
34+
super().__init__()
35+
self._status_trait = status_trait
36+
37+
@property
38+
def current_map(self) -> int | None:
39+
"""Returns the currently active map (map_flag), if available."""
40+
return self._status_trait.current_map
41+
42+
@property
43+
def current_map_info(self) -> MultiMapsListMapInfo | None:
44+
"""Returns the currently active map info, if available."""
45+
if (current_map := self.current_map) is None or self.map_info is None:
46+
return None
47+
for map_info in self.map_info:
48+
if map_info.map_flag == current_map:
49+
return map_info
50+
return None
51+
52+
async def set_current_map(self, map_flag: int) -> None:
53+
"""Update the current map of the device by it's map_flag id."""
54+
await self.rpc_channel.send_command(RoborockCommand.LOAD_MULTI_MAP, params=[map_flag])
55+
# Refresh our status to make sure it reflects the new map
56+
await self._status_trait.refresh()
57+
58+
def _parse_response(self, response: common.V1ResponseData) -> Self:
59+
"""Parse the response from the device into a MapsTrait instance.
60+
61+
This overrides the base implementation to handle the specific
62+
response format for the multi maps list. This is needed because we have
63+
a custom constructor that requires the StatusTrait.
64+
"""
65+
if not isinstance(response, list):
66+
raise ValueError(f"Unexpected MapsTrait response format: {response!r}")
67+
response = response[0]
68+
if not isinstance(response, dict):
69+
raise ValueError(f"Unexpected MapsTrait response format: {response!r}")
70+
return MultiMapsList.from_dict(response)
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Tests for the Maps 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.maps import MapsTrait
9+
from roborock.devices.traits.v1.status import StatusTrait
10+
from roborock.roborock_typing import RoborockCommand
11+
from tests import mock_data
12+
13+
UPDATED_STATUS = {
14+
**mock_data.STATUS,
15+
"map_status": 123 * 4 + 3, # Set current map to 123
16+
}
17+
18+
19+
MULTI_MAP_LIST_DATA = [
20+
{
21+
"max_multi_map": 1,
22+
"max_bak_map": 1,
23+
"multi_map_count": 1,
24+
"map_info": [
25+
{
26+
"mapFlag": 0,
27+
"add_time": 1747132930,
28+
"length": 0,
29+
"name": "Map 1",
30+
"bak_maps": [{"mapFlag": 4, "add_time": 1747132936}],
31+
},
32+
{
33+
"mapFlag": 123,
34+
"add_time": 1747132930,
35+
"length": 0,
36+
"name": "Map 1",
37+
"bak_maps": [{"mapFlag": 4, "add_time": 1747132936}],
38+
},
39+
],
40+
}
41+
]
42+
43+
44+
@pytest.fixture
45+
def status_trait(device: RoborockDevice) -> StatusTrait:
46+
"""Create a MapsTrait instance with mocked dependencies."""
47+
assert device.v1_properties
48+
return device.v1_properties.status
49+
50+
51+
@pytest.fixture
52+
def maps_trait(device: RoborockDevice) -> MapsTrait:
53+
"""Create a MapsTrait instance with mocked dependencies."""
54+
assert device.v1_properties
55+
return device.v1_properties.maps
56+
57+
58+
async def test_refresh_maps_trait(
59+
maps_trait: MapsTrait,
60+
mock_rpc_channel: AsyncMock,
61+
) -> None:
62+
"""Test successfully getting multi maps list."""
63+
# Setup mock to return the sample multi maps list
64+
mock_rpc_channel.send_command.return_value = MULTI_MAP_LIST_DATA
65+
66+
# Call the method
67+
await maps_trait.refresh()
68+
69+
assert maps_trait.max_multi_map == 1
70+
assert maps_trait.max_bak_map == 1
71+
assert maps_trait.multi_map_count == 1
72+
assert maps_trait.map_info
73+
assert len(maps_trait.map_info) == 2
74+
map_infos = maps_trait.map_info
75+
assert len(map_infos) == 2
76+
assert map_infos[0].map_flag == 0
77+
assert map_infos[0].name == "Map 1"
78+
assert map_infos[0].add_time == 1747132930
79+
assert map_infos[1].map_flag == 123
80+
assert map_infos[1].name == "Map 1"
81+
assert map_infos[1].add_time == 1747132930
82+
83+
# Verify the RPC call was made correctly
84+
mock_rpc_channel.send_command.assert_called_once_with(RoborockCommand.GET_MULTI_MAPS_LIST)
85+
86+
87+
async def test_set_current_map(
88+
status_trait: StatusTrait,
89+
maps_trait: MapsTrait,
90+
mock_rpc_channel: AsyncMock,
91+
) -> None:
92+
"""Test successfully setting the current map."""
93+
mock_rpc_channel.send_command.side_effect = [
94+
mock_data.STATUS, # Initial status fetch
95+
MULTI_MAP_LIST_DATA, # Response for LOAD_MULTI_MAP
96+
{}, # Response for setting the current map
97+
UPDATED_STATUS, # Response for refreshing status
98+
]
99+
await status_trait.refresh()
100+
101+
# First refresh to populate initial state
102+
await maps_trait.refresh()
103+
104+
# Call the method to set current map
105+
await maps_trait.set_current_map(123)
106+
107+
# Verify the current map is updated
108+
assert maps_trait.current_map == 123
109+
110+
# Verify the RPC call was made correctly to load the map
111+
mock_rpc_channel.send_command.assert_any_call(RoborockCommand.LOAD_MULTI_MAP, params=[123])
112+
# Command sent are:
113+
# 1. GET_STATUS to get initial status
114+
# 2. GET_MULTI_MAPS_LIST to get the map list
115+
# 3. LOAD_MULTI_MAP to set the map
116+
# 4. GET_STATUS to refresh the current map in status
117+
assert mock_rpc_channel.send_command.call_count == 4

tests/protocols/__snapshots__/test_v1_protocol.ambr

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,34 @@
4949
]
5050
'''
5151
# ---
52+
# name: test_decode_rpc_payload[get_multi_maps_list]
53+
20001
54+
# ---
55+
# name: test_decode_rpc_payload[get_multi_maps_list].1
56+
'''
57+
[
58+
{
59+
"max_multi_map": 1,
60+
"max_bak_map": 1,
61+
"multi_map_count": 1,
62+
"map_info": [
63+
{
64+
"mapFlag": 0,
65+
"add_time": 1747132930,
66+
"length": 0,
67+
"name": "",
68+
"bak_maps": [
69+
{
70+
"mapFlag": 4,
71+
"add_time": 1747132936
72+
}
73+
]
74+
}
75+
]
76+
}
77+
]
78+
'''
79+
# ---
5280
# name: test_decode_rpc_payload[get_status]
5381
20001
5482
# ---
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"t":1758987228,"dps":{"102":"{\"id\":20001,\"result\":[{\"max_multi_map\":1,\"max_bak_map\":1,\"multi_map_count\":1,\"map_info\":[{\"mapFlag\":0,\"add_time\":1747132930,\"length\":0,\"name\":\"\",\"bak_maps\":[{\"mapFlag\":4,\"add_time\":1747132936}]}]}]}"}}

0 commit comments

Comments
 (0)