Skip to content

Commit e49b3ea

Browse files
authored
Add a v1 device trait for map contents (#517)
* 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. * chore: Refactor to reuse the same payload functions * chore: remove duplicate code * chore: fix lint and typing errors * chore: fix test wording * chore: fix lint error * chore: fix formatting
1 parent 98188f8 commit e49b3ea

File tree

14 files changed

+297
-18
lines changed

14 files changed

+297
-18
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
@@ -727,6 +771,8 @@ def write_markdown_table(product_features: dict[str, dict[str, any]], all_featur
727771
cli.add_command(volume)
728772
cli.add_command(set_volume)
729773
cli.add_command(maps)
774+
cli.add_command(map_image)
775+
cli.add_command(map_data)
730776
cli.add_command(consumables)
731777
cli.add_command(reset_consumable)
732778
cli.add_command(rooms)

roborock/devices/device_manager.py

Lines changed: 10 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,14 @@ def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDat
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, home_data, channel.rpc_channel, channel.mqtt_rpc_channel)
154+
trait = v1.create(
155+
product,
156+
home_data,
157+
channel.rpc_channel,
158+
channel.mqtt_rpc_channel,
159+
channel.map_rpc_channel,
160+
map_parser_config=map_parser_config,
161+
)
153162
case DeviceVersion.A01:
154163
channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)
155164
trait = a01.create(product, channel)

roborock/devices/traits/v1/__init__.py

Lines changed: 21 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 .rooms import RoomsTrait
1618
from .status import StatusTrait
@@ -26,6 +28,7 @@
2628
"CleanSummaryTrait",
2729
"SoundVolumeTrait",
2830
"MapsTrait",
31+
"MapContentTrait",
2932
"ConsumableTrait",
3033
]
3134

@@ -44,18 +47,25 @@ class PropertiesApi(Trait):
4447
sound_volume: SoundVolumeTrait
4548
rooms: RoomsTrait
4649
maps: MapsTrait
50+
map_content: MapContentTrait
4751
consumables: ConsumableTrait
4852

4953
# In the future optional fields can be added below based on supported features
5054

5155
def __init__(
52-
self, product: HomeDataProduct, home_data: HomeData, rpc_channel: V1RpcChannel, mqtt_rpc_channel: V1RpcChannel
56+
self,
57+
product: HomeDataProduct,
58+
home_data: HomeData,
59+
rpc_channel: V1RpcChannel,
60+
mqtt_rpc_channel: V1RpcChannel,
61+
map_rpc_channel: V1RpcChannel,
62+
map_parser_config: MapParserConfig | None = None,
5363
) -> None:
5464
"""Initialize the V1TraitProps."""
5565
self.status = StatusTrait(product)
5666
self.rooms = RoomsTrait(home_data)
5767
self.maps = MapsTrait(self.status)
58-
68+
self.map_content = MapContentTrait(map_parser_config)
5969
# This is a hack to allow setting the rpc_channel on all traits. This is
6070
# used so we can preserve the dataclass behavior when the values in the
6171
# traits are updated, but still want to allow them to have a reference
@@ -68,12 +78,19 @@ def __init__(
6878
# to use the mqtt_rpc_channel (cloud only) instead of the rpc_channel (adaptive)
6979
if hasattr(trait, "mqtt_rpc_channel"):
7080
trait._rpc_channel = mqtt_rpc_channel
81+
elif hasattr(trait, "map_rpc_channel"):
82+
trait._rpc_channel = map_rpc_channel
7183
else:
7284
trait._rpc_channel = rpc_channel
7385

7486

7587
def create(
76-
product: HomeDataProduct, home_data: HomeData, rpc_channel: V1RpcChannel, mqtt_rpc_channel: V1RpcChannel
88+
product: HomeDataProduct,
89+
home_data: HomeData,
90+
rpc_channel: V1RpcChannel,
91+
mqtt_rpc_channel: V1RpcChannel,
92+
map_rpc_channel: V1RpcChannel,
93+
map_parser_config: MapParserConfig | None = None,
7794
) -> PropertiesApi:
7895
"""Create traits for V1 devices."""
79-
return PropertiesApi(product, home_data, rpc_channel, mqtt_rpc_channel)
96+
return PropertiesApi(product, home_data, rpc_channel, mqtt_rpc_channel, map_rpc_channel, map_parser_config)

roborock/devices/traits/v1/common.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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())

roborock/devices/v1_rpc_channel.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
and local connections, preferring local when available.
77
"""
88

9+
910
import asyncio
1011
import logging
1112
from collections.abc import Callable
@@ -15,10 +16,13 @@
1516
from roborock.exceptions import RoborockException
1617
from roborock.protocols.v1_protocol import (
1718
CommandType,
19+
MapResponse,
1820
ParamsType,
1921
RequestMessage,
2022
ResponseData,
23+
ResponseMessage,
2124
SecurityData,
25+
create_map_response_decoder,
2226
decode_rpc_response,
2327
)
2428
from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol
@@ -31,6 +35,7 @@
3135

3236

3337
_T = TypeVar("_T", bound=RoborockBase)
38+
_V = TypeVar("_V")
3439

3540

3641
class V1RpcChannel(Protocol):
@@ -120,36 +125,40 @@ def __init__(
120125
name: str,
121126
channel: MqttChannel | LocalChannel,
122127
payload_encoder: Callable[[RequestMessage], RoborockMessage],
128+
decoder: Callable[[RoborockMessage], ResponseMessage] | Callable[[RoborockMessage], MapResponse | None],
123129
) -> None:
124130
"""Initialize the channel with a raw channel and an encoder function."""
125131
self._name = name
126132
self._channel = channel
127133
self._payload_encoder = payload_encoder
134+
self._decoder = decoder
128135

129136
async def _send_raw_command(
130137
self,
131138
method: CommandType,
132139
*,
133140
params: ParamsType = None,
134-
) -> ResponseData:
141+
) -> ResponseData | bytes:
135142
"""Send a command and return a parsed response RoborockBase type."""
136143
request_message = RequestMessage(method, params=params)
137144
_LOGGER.debug(
138145
"Sending command (%s, request_id=%s): %s, params=%s", self._name, request_message.request_id, method, params
139146
)
140147
message = self._payload_encoder(request_message)
141148

142-
future: asyncio.Future[ResponseData] = asyncio.Future()
149+
future: asyncio.Future[ResponseData | bytes] = asyncio.Future()
143150

144151
def find_response(response_message: RoborockMessage) -> None:
145152
try:
146-
decoded = decode_rpc_response(response_message)
153+
decoded = self._decoder(response_message)
147154
except RoborockException as ex:
148155
_LOGGER.debug("Exception while decoding message (%s): %s", response_message, ex)
149156
return
150-
_LOGGER.debug("Received response (request_id=%s): %s", self._name, decoded.request_id)
157+
if decoded is None:
158+
return
159+
_LOGGER.debug("Received response (%s, request_id=%s)", self._name, decoded.request_id)
151160
if decoded.request_id == request_message.request_id:
152-
if decoded.api_error:
161+
if isinstance(decoded, ResponseMessage) and decoded.api_error:
153162
future.set_exception(decoded.api_error)
154163
else:
155164
future.set_result(decoded.data)
@@ -171,6 +180,7 @@ def create_mqtt_rpc_channel(mqtt_channel: MqttChannel, security_data: SecurityDa
171180
"mqtt",
172181
mqtt_channel,
173182
lambda x: x.encode_message(RoborockMessageProtocol.RPC_REQUEST, security_data=security_data),
183+
decode_rpc_response,
174184
)
175185

176186

@@ -180,4 +190,23 @@ def create_local_rpc_channel(local_channel: LocalChannel) -> V1RpcChannel:
180190
"local",
181191
local_channel,
182192
lambda x: x.encode_message(RoborockMessageProtocol.GENERAL_REQUEST),
193+
decode_rpc_response,
194+
)
195+
196+
197+
def create_map_rpc_channel(
198+
mqtt_channel: MqttChannel,
199+
security_data: SecurityData,
200+
) -> V1RpcChannel:
201+
"""Create a V1 RPC channel that fetches map data.
202+
203+
This will prefer local channels when available, falling back to MQTT
204+
channels if not. If neither is available, an exception will be raised
205+
when trying to send a command.
206+
"""
207+
return PayloadEncodedV1RpcChannel(
208+
"map",
209+
mqtt_channel,
210+
lambda x: x.encode_message(RoborockMessageProtocol.RPC_REQUEST, security_data=security_data),
211+
create_map_response_decoder(security_data=security_data),
183212
)

roborock/map/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Module for Roborock map related data classes."""
2+
3+
from .map_parser import MapParserConfig, ParsedMapData
4+
5+
__all__ = [
6+
"MapParserConfig",
7+
]

roborock/protocols/v1_protocol.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ def _decode_map_response(message: RoborockMessage) -> MapResponse | None:
187187
header, body = message.payload[:24], message.payload[24:]
188188
[endpoint, _, request_id, _] = struct.unpack("<8s8sH6s", header)
189189
if not endpoint.decode().startswith(security_data.endpoint):
190-
_LOGGER.debug("Received map response requested not made by this device, ignoring.")
190+
_LOGGER.debug("Received map response not requested by this device, ignoring.")
191191
return None
192192
try:
193193
decrypted = Utils.decrypt_cbc(body, security_data.nonce)

0 commit comments

Comments
 (0)