Skip to content

Commit 1e7c4ef

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 df6c674 commit 1e7c4ef

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
@@ -48,6 +48,7 @@
4848
from roborock.devices.traits import Trait
4949
from roborock.devices.traits.v1 import V1TraitMixin
5050
from roborock.devices.traits.v1.consumeable import ConsumableAttribute
51+
from roborock.devices.traits.v1.map_content import MapContentTrait
5152
from roborock.protocol import MessageParser
5253
from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1
5354
from roborock.web_api import RoborockApiClient
@@ -451,6 +452,49 @@ async def maps(ctx, device_id: str):
451452
await _display_v1_trait(context, device_id, lambda v1: v1.maps)
452453

453454

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

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,11 +6,13 @@
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 .consumeable import ConsumableTrait
1314
from .do_not_disturb import DoNotDisturbTrait
15+
from .map_content import MapContentTrait
1416
from .maps import MapsTrait
1517
from .status import StatusTrait
1618
from .volume import SoundVolumeTrait
@@ -25,6 +27,7 @@
2527
"CleanSummaryTrait",
2628
"SoundVolumeTrait",
2729
"MapsTrait",
30+
"MapContentTrait",
2831
"ConsumableTrait",
2932
]
3033

@@ -42,15 +45,23 @@ class PropertiesApi(Trait):
4245
clean_summary: CleanSummaryTrait
4346
sound_volume: SoundVolumeTrait
4447
maps: MapsTrait
48+
map_content: MapContentTrait
4549
consumables: ConsumableTrait
4650

4751
# In the future optional fields can be added below based on supported features
4852

49-
def __init__(self, product: HomeDataProduct, rpc_channel: V1RpcChannel, mqtt_rpc_channel: V1RpcChannel) -> None:
53+
def __init__(
54+
self,
55+
product: HomeDataProduct,
56+
rpc_channel: V1RpcChannel,
57+
mqtt_rpc_channel: V1RpcChannel,
58+
map_rpc_channel: V1RpcChannel,
59+
map_parser_config: MapParserConfig | None = None,
60+
) -> None:
5061
"""Initialize the V1TraitProps with None values."""
5162
self.status = StatusTrait(product)
5263
self.maps = MapsTrait(self.status)
53-
64+
self.map_content = MapContentTrait(map_parser_config)
5465
# This is a hack to allow setting the rpc_channel on all traits. This is
5566
# used so we can preserve the dataclass behavior when the values in the
5667
# traits are updated, but still want to allow them to have a reference
@@ -63,10 +74,18 @@ def __init__(self, product: HomeDataProduct, rpc_channel: V1RpcChannel, mqtt_rpc
6374
# to use the mqtt_rpc_channel (cloud only) instead of the rpc_channel (adaptive)
6475
if hasattr(trait, "mqtt_rpc_channel"):
6576
trait._rpc_channel = mqtt_rpc_channel
77+
elif hasattr(trait, "map_rpc_channel"):
78+
trait._rpc_channel = map_rpc_channel
6679
else:
6780
trait._rpc_channel = rpc_channel
6881

6982

70-
def create(product: HomeDataProduct, rpc_channel: V1RpcChannel, mqtt_rpc_channel: V1RpcChannel) -> PropertiesApi:
83+
def create(
84+
product: HomeDataProduct,
85+
rpc_channel: V1RpcChannel,
86+
mqtt_rpc_channel: V1RpcChannel,
87+
map_rpc_channel: V1RpcChannel,
88+
map_parser_config: MapParserConfig | None = None,
89+
) -> PropertiesApi:
7190
"""Create traits for V1 devices."""
72-
return PropertiesApi(product, rpc_channel, mqtt_rpc_channel)
91+
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)