Skip to content

Commit d43660b

Browse files
committed
feat: Add a v1 device trait for map contents
This uses the map parser library to return an image and the parsed data from a call.
1 parent e9ba1e3 commit d43660b

File tree

13 files changed

+366
-36
lines changed

13 files changed

+366
-36
lines changed

roborock/cli.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
from roborock.devices.device_manager import DeviceManager, create_device_manager, create_home_data_api
4747
from roborock.devices.traits import Trait
4848
from roborock.devices.traits.v1 import V1TraitMixin
49+
from roborock.devices.traits.v1.map_content import MapContentTrait
4950
from roborock.protocol import MessageParser
5051
from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1
5152
from roborock.web_api import RoborockApiClient
@@ -449,6 +450,49 @@ async def maps(ctx, device_id: str):
449450
await _display_v1_trait(context, device_id, lambda v1: v1.maps)
450451

451452

453+
@session.command()
454+
@click.option("--device_id", required=True)
455+
@click.option("--output-file", required=True, help="Path to save the map image.")
456+
@click.pass_context
457+
@async_command
458+
async def map_image(ctx, device_id: str, output_file: str):
459+
"""Get device map image and save it to a file."""
460+
context: RoborockContext = ctx.obj
461+
trait: MapContentTrait = await _v1_trait(context, device_id, lambda v1: v1.map_content)
462+
if trait.image_content:
463+
with open(output_file, "wb") as f:
464+
f.write(trait.image_content)
465+
click.echo(f"Map image saved to {output_file}")
466+
else:
467+
click.echo("No map image content available.")
468+
469+
470+
@session.command()
471+
@click.option("--device_id", required=True)
472+
@click.option("--include_path", is_flag=True, default=False, help="Include path data in the output.")
473+
@click.pass_context
474+
@async_command
475+
async def map_data(ctx, device_id: str, include_path: bool):
476+
"""Get parsed map data as JSON."""
477+
context: RoborockContext = ctx.obj
478+
trait: MapContentTrait = await _v1_trait(context, device_id, lambda v1: v1.map_content)
479+
if not trait.map_data:
480+
click.echo("No parsed map data available.")
481+
return
482+
483+
# Pick some parts of the map data to display.
484+
data_summary = {
485+
"charger": trait.map_data.charger.as_dict() if trait.map_data.charger else None,
486+
"image_size": trait.map_data.image.data.size if trait.map_data.image else None,
487+
"vacuum_position": trait.map_data.vacuum_position.as_dict() if trait.map_data.vacuum_position else None,
488+
"calibration": trait.map_data.calibration(),
489+
"zones": [z.as_dict() for z in trait.map_data.zones or ()],
490+
}
491+
if include_path and trait.map_data.path:
492+
data_summary["path"] = trait.map_data.path.as_dict()
493+
click.echo(dump_json(data_summary))
494+
495+
452496
@click.command()
453497
@click.option("--device_id", required=True)
454498
@click.option("--cmd", required=True)
@@ -691,6 +735,8 @@ def write_markdown_table(product_features: dict[str, dict[str, any]], all_featur
691735
cli.add_command(volume)
692736
cli.add_command(set_volume)
693737
cli.add_command(maps)
738+
cli.add_command(map_image)
739+
cli.add_command(map_data)
694740

695741

696742
def main():

roborock/devices/device_manager.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
UserData,
1515
)
1616
from roborock.devices.device import RoborockDevice
17+
from roborock.map.map_parser import MapParserConfig
1718
from roborock.mqtt.roborock_session import create_lazy_mqtt_session
1819
from roborock.mqtt.session import MqttSession
1920
from roborock.protocol import create_mqtt_params
@@ -130,6 +131,7 @@ async def create_device_manager(
130131
user_data: UserData,
131132
home_data_api: HomeDataApi,
132133
cache: Cache | None = None,
134+
map_parser_config: MapParserConfig | None = None,
133135
) -> DeviceManager:
134136
"""Convenience function to create and initialize a DeviceManager.
135137
@@ -149,7 +151,13 @@ def device_creator(device: HomeDataDevice, product: HomeDataProduct) -> Roborock
149151
match device.pv:
150152
case DeviceVersion.V1:
151153
channel = create_v1_channel(user_data, mqtt_params, mqtt_session, device, cache)
152-
trait = v1.create(product, channel.rpc_channel, channel.mqtt_rpc_channel)
154+
trait = v1.create(
155+
product,
156+
channel.rpc_channel,
157+
channel.mqtt_rpc_channel,
158+
channel.map_rpc_channel,
159+
map_parser_config=map_parser_config,
160+
)
153161
case DeviceVersion.A01:
154162
channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
155163
trait = a01.create(product, channel)

roborock/devices/traits/v1/__init__.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
from roborock.containers import HomeData, HomeDataProduct
77
from roborock.devices.traits import Trait
88
from roborock.devices.v1_rpc_channel import V1RpcChannel
9+
from roborock.map.map_parser import MapParserConfig
910

1011
from .clean_summary import CleanSummaryTrait
1112
from .common import V1TraitMixin
1213
from .do_not_disturb import DoNotDisturbTrait
14+
from .map_content import MapContentTrait
1315
from .maps import MapsTrait
1416
from .status import StatusTrait
1517
from .volume import SoundVolumeTrait
@@ -24,6 +26,7 @@
2426
"CleanSummaryTrait",
2527
"SoundVolumeTrait",
2628
"MapsTrait",
29+
"MapContentTrait",
2730
]
2831

2932

@@ -40,14 +43,22 @@ class PropertiesApi(Trait):
4043
clean_summary: CleanSummaryTrait
4144
sound_volume: SoundVolumeTrait
4245
maps: MapsTrait
46+
map_content: MapContentTrait
4347

4448
# In the future optional fields can be added below based on supported features
4549

46-
def __init__(self, product: HomeDataProduct, rpc_channel: V1RpcChannel, mqtt_rpc_channel: V1RpcChannel) -> None:
50+
def __init__(
51+
self,
52+
product: HomeDataProduct,
53+
rpc_channel: V1RpcChannel,
54+
mqtt_rpc_channel: V1RpcChannel,
55+
map_rpc_channel: V1RpcChannel,
56+
map_parser_config: MapParserConfig | None = None,
57+
) -> None:
4758
"""Initialize the V1TraitProps with None values."""
4859
self.status = StatusTrait(product)
4960
self.maps = MapsTrait(self.status)
50-
61+
self.map_content = MapContentTrait(map_parser_config)
5162
# This is a hack to allow setting the rpc_channel on all traits. This is
5263
# used so we can preserve the dataclass behavior when the values in the
5364
# traits are updated, but still want to allow them to have a reference
@@ -60,10 +71,18 @@ def __init__(self, product: HomeDataProduct, rpc_channel: V1RpcChannel, mqtt_rpc
6071
# to use the mqtt_rpc_channel (cloud only) instead of the rpc_channel (adaptive)
6172
if hasattr(trait, "mqtt_rpc_channel"):
6273
trait._rpc_channel = mqtt_rpc_channel
74+
elif hasattr(trait, "map_rpc_channel"):
75+
trait._rpc_channel = map_rpc_channel
6376
else:
6477
trait._rpc_channel = rpc_channel
6578

6679

67-
def create(product: HomeDataProduct, rpc_channel: V1RpcChannel, mqtt_rpc_channel: V1RpcChannel) -> PropertiesApi:
80+
def create(
81+
product: HomeDataProduct,
82+
rpc_channel: V1RpcChannel,
83+
mqtt_rpc_channel: V1RpcChannel,
84+
map_rpc_channel: V1RpcChannel,
85+
map_parser_config: MapParserConfig | None = None,
86+
) -> PropertiesApi:
6887
"""Create traits for V1 devices."""
69-
return PropertiesApi(product, rpc_channel, mqtt_rpc_channel)
88+
return PropertiesApi(product, rpc_channel, mqtt_rpc_channel, map_rpc_channel, map_parser_config)

roborock/devices/traits/v1/common.py

Lines changed: 12 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
@@ -133,3 +133,13 @@ def wrapper(*args, **kwargs):
133133

134134
cls.mqtt_rpc_channel = True # type: ignore[attr-defined]
135135
return wrapper
136+
137+
138+
def map_rpc_channel(cls):
139+
"""Decorator to mark a function as cloud only using the map rpc format."""
140+
141+
def wrapper(*args, **kwargs):
142+
return cls(*args, **kwargs)
143+
144+
cls.map_rpc_channel = True # type: ignore[attr-defined]
145+
return wrapper
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Trait for fetching the map content from Roborock devices."""
2+
import logging
3+
from dataclasses import dataclass
4+
5+
from vacuum_map_parser_base.map_data import MapData
6+
7+
from roborock.containers import RoborockBase
8+
from roborock.devices.traits.v1 import common
9+
from roborock.map.map_parser import MapParser, MapParserConfig
10+
from roborock.roborock_typing import RoborockCommand
11+
12+
_LOGGER = logging.getLogger(__name__)
13+
14+
15+
@dataclass
16+
class MapContent(RoborockBase):
17+
"""Dataclass representing map content."""
18+
19+
image_content: bytes | None = None
20+
"""The rendered image of the map in PNG format."""
21+
22+
map_data: MapData | None = None
23+
"""The parsed map data which contains metadata for points on the map."""
24+
25+
26+
@common.map_rpc_channel
27+
class MapContentTrait(MapContent, common.V1TraitMixin):
28+
"""Trait for fetching the map content."""
29+
30+
command = RoborockCommand.GET_MAP_V1
31+
32+
def __init__(self, map_parser_config: MapParserConfig | None = None) -> None:
33+
"""Initialize MapContentTrait."""
34+
super().__init__()
35+
self._map_parser = MapParser(map_parser_config or MapParserConfig())
36+
37+
def _parse_response(self, response: common.V1ResponseData) -> MapContent:
38+
"""Parse the response from the device into a MapContentTrait instance."""
39+
if not isinstance(response, bytes):
40+
raise ValueError(f"Unexpected MapContentTrait response format: {type(response)}")
41+
42+
parsed_data = self._map_parser.parse(response)
43+
if parsed_data is None:
44+
raise ValueError("Failed to parse map data")
45+
46+
return MapContent(
47+
image_content=parsed_data.image_content,
48+
map_data=parsed_data.map_data,
49+
)

roborock/devices/v1_channel.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
PickFirstAvailable,
2828
V1RpcChannel,
2929
create_local_rpc_channel,
30+
create_map_rpc_channel,
3031
create_mqtt_rpc_channel,
3132
)
3233

@@ -80,6 +81,7 @@ def __init__(
8081
self._combined_rpc_channel = PickFirstAvailable(
8182
[lambda: self._local_rpc_channel, lambda: self._mqtt_rpc_channel]
8283
)
84+
self._map_rpc_channel = create_map_rpc_channel(mqtt_channel, security_data)
8385
self._mqtt_unsub: Callable[[], None] | None = None
8486
self._local_unsub: Callable[[], None] | None = None
8587
self._callback: Callable[[RoborockMessage], None] | None = None
@@ -112,6 +114,11 @@ def mqtt_rpc_channel(self) -> V1RpcChannel:
112114
"""Return the MQTT RPC channel."""
113115
return self._mqtt_rpc_channel
114116

117+
@property
118+
def map_rpc_channel(self) -> V1RpcChannel:
119+
"""Return the map RPC channel used for fetching map content."""
120+
return self._map_rpc_channel
121+
115122
async def subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callable[[], None]:
116123
"""Subscribe to all messages from the device.
117124
@@ -132,7 +139,6 @@ async def subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callab
132139

133140
# Start a background task to manage the local connection health. This
134141
# happens independent of whether we were able to connect locally now.
135-
_LOGGER.info("self._reconnect_task=%s", self._reconnect_task)
136142
if self._reconnect_task is None:
137143
loop = asyncio.get_running_loop()
138144
self._reconnect_task = loop.create_task(self._background_reconnect())

0 commit comments

Comments
 (0)