Skip to content

Commit f0579b8

Browse files
authored
feat: add photo for obstacle avoidance (#880)
* feat: add photo for obstacle avoidance * chore: clean up security location * chore: mild clean ups
1 parent 3c5f788 commit f0579b8

16 files changed

Lines changed: 495 additions & 5 deletions

roborock/device_features.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,12 @@ class DeviceFeatures(RoborockBase):
539539
]
540540
}
541541
)
542+
is_ai_recognition_setting_supported: bool = field(
543+
metadata={"product_features": [ProductFeatures.AIRECOGNITION_SETTING]}
544+
)
545+
is_ai_recognition_obstacle_supported: bool = field(
546+
metadata={"product_features": [ProductFeatures.AIRECOGNITION_OBSTACLE]}
547+
)
542548
is_pure_clean_mop_supported: bool = field(metadata={"product_features": [ProductFeatures.CLEANMODE_PURECLEANMOP]})
543549
is_new_remote_view_supported: bool = field(metadata={"product_features": [ProductFeatures.REMOTE_BACK]})
544550
is_max_plus_mode_supported: bool = field(metadata={"product_features": [ProductFeatures.CLEANMODE_MAXPLUS]})

roborock/devices/device_manager.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDat
246246
channel.rpc_channel,
247247
channel.mqtt_rpc_channel,
248248
channel.map_rpc_channel,
249+
channel.blob_rpc_channel,
249250
channel.add_dps_listener,
250251
web_api,
251252
device_cache=device_cache,

roborock/devices/rpc/v1_channel.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
ResponseMessage,
3030
SecurityData,
3131
V1RpcChannel,
32+
create_blob_response_decoder,
3233
create_map_response_decoder,
3334
create_security_data,
3435
decode_data_protocol_message,
@@ -161,6 +162,50 @@ def find_response(response_message: RoborockMessage) -> None:
161162
return result
162163

163164

165+
class BlobRpcChannel(V1RpcChannel):
166+
"""RPC channel that adds the security envelope required for blob requests."""
167+
168+
def __init__(
169+
self,
170+
rpc_channel: V1RpcChannel,
171+
raw_blob_rpc_channel: V1RpcChannel,
172+
security_data: SecurityData,
173+
) -> None:
174+
"""Initialize the blob RPC channel."""
175+
self._rpc_channel = rpc_channel
176+
self._raw_blob_rpc_channel = raw_blob_rpc_channel
177+
self._security_data = security_data
178+
179+
async def send_command(
180+
self,
181+
method: CommandType,
182+
*,
183+
response_type: type[_T] | None = None,
184+
params: ParamsType = None,
185+
) -> _T | Any:
186+
"""Send a blob command after adding the device security parameters."""
187+
public_key = await self._rpc_channel.send_command(RoborockCommand.GET_RANDOM_PKEY)
188+
if not isinstance(public_key, dict) or not isinstance(public_key.get("pub_key"), dict):
189+
raise RoborockException("get_random_pkey response did not contain a public key")
190+
security = self._security_data.to_dict()["security"]
191+
blob_params = {
192+
"security": {
193+
"pub_key": public_key["pub_key"],
194+
"cipher_suite": 0,
195+
},
196+
"endpoint": security["endpoint"],
197+
"nonce": security["nonce"],
198+
"data_filter": params,
199+
}
200+
if response_type is not None:
201+
return await self._raw_blob_rpc_channel.send_command(
202+
method,
203+
response_type=response_type,
204+
params=blob_params,
205+
)
206+
return await self._raw_blob_rpc_channel.send_command(method, params=blob_params)
207+
208+
164209
class V1Channel(Channel):
165210
"""Unified V1 protocol channel with automatic MQTT/local connection handling.
166211
@@ -245,6 +290,13 @@ def map_rpc_channel(self) -> V1RpcChannel:
245290
decoder = create_map_response_decoder(security_data=self._security_data)
246291
return RpcChannel(lambda: [self._create_mqtt_rpc_strategy(decoder)], self._logger)
247292

293+
@property
294+
def blob_rpc_channel(self) -> V1RpcChannel:
295+
"""Return the blob RPC channel used for fetching binary content."""
296+
decoder = create_blob_response_decoder()
297+
raw_blob_rpc_channel = RpcChannel(lambda: [self._create_mqtt_rpc_strategy(decoder)], self._logger)
298+
return BlobRpcChannel(self.rpc_channel, raw_blob_rpc_channel, self._security_data)
299+
248300
def _create_local_rpc_strategy(self) -> RpcStrategy | None:
249301
"""Create the RPC strategy for local transport."""
250302
if self._local_channel is None or not self.is_local_connected:

roborock/devices/traits/v1/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
map_content,
8484
maps,
8585
network_info,
86+
obstacle_photos,
8687
rooms,
8788
routines,
8889
smart_wash_params,
@@ -105,6 +106,7 @@
105106
from .map_content import MapContentTrait
106107
from .maps import MapsTrait
107108
from .network_info import NetworkInfoTrait
109+
from .obstacle_photos import ObstaclePhotoTrait
108110
from .rooms import RoomsTrait
109111
from .routines import RoutinesTrait
110112
from .smart_wash_params import SmartWashParamsTrait
@@ -131,6 +133,7 @@
131133
"map_content",
132134
"maps",
133135
"network_info",
136+
"obstacle_photos",
134137
"rooms",
135138
"routines",
136139
"smart_wash_params",
@@ -171,6 +174,7 @@ class PropertiesApi(Trait):
171174
dust_collection_mode: DustCollectionModeTrait | None = None
172175
wash_towel_mode: WashTowelModeTrait | None = None
173176
smart_wash_params: SmartWashParamsTrait | None = None
177+
obstacle_photos: ObstaclePhotoTrait | None = None
174178

175179
def __init__(
176180
self,
@@ -180,6 +184,7 @@ def __init__(
180184
rpc_channel: V1RpcChannel,
181185
mqtt_rpc_channel: V1RpcChannel,
182186
map_rpc_channel: V1RpcChannel,
187+
blob_rpc_channel: V1RpcChannel,
183188
add_dps_listener: Callable[[Callable[[dict[RoborockDataProtocol, Any]], None]], Callable[[], None]],
184189
web_api: UserWebApiClient,
185190
device_cache: DeviceCache,
@@ -191,6 +196,7 @@ def __init__(
191196
self._rpc_channel = rpc_channel
192197
self._mqtt_rpc_channel = mqtt_rpc_channel
193198
self._map_rpc_channel = map_rpc_channel
199+
self._blob_rpc_channel = blob_rpc_channel
194200
self._web_api = web_api
195201
self._device_cache = device_cache
196202
self._region = region
@@ -229,6 +235,8 @@ def _get_rpc_channel(self, trait: V1TraitMixin) -> V1RpcChannel:
229235
# to use the mqtt_rpc_channel (cloud only) instead of the rpc_channel (adaptive)
230236
if hasattr(trait, "mqtt_rpc_channel"):
231237
return self._mqtt_rpc_channel
238+
elif hasattr(trait, "blob_rpc_channel"):
239+
return self._blob_rpc_channel
232240
elif hasattr(trait, "map_rpc_channel"):
233241
return self._map_rpc_channel
234242
else:
@@ -272,6 +280,11 @@ async def discover_features(self) -> None:
272280
wash_towel_mode._rpc_channel = self._get_rpc_channel(wash_towel_mode) # type: ignore[assignment]
273281
self.wash_towel_mode = wash_towel_mode
274282

283+
if self.obstacle_photos is None and self._is_supported(ObstaclePhotoTrait, "obstacle_photos", dock_features):
284+
obstacle_photos = ObstaclePhotoTrait(self._rpc_channel)
285+
obstacle_photos._rpc_channel = self._get_rpc_channel(obstacle_photos)
286+
self.obstacle_photos = obstacle_photos
287+
275288
# Dynamically create any traits that need to be populated
276289
for item in fields(self):
277290
if (trait := getattr(self, item.name, None)) is not None:
@@ -283,6 +296,8 @@ async def discover_features(self) -> None:
283296

284297
# Union args may not be in declared order
285298
item_type = union_args[0] if union_args[1] is type(None) else union_args[1]
299+
if item_type is ObstaclePhotoTrait:
300+
continue
286301
if not self._is_supported(item_type, item.name, dock_features):
287302
_LOGGER.debug("Trait '%s' not supported, skipping", item.name)
288303
continue
@@ -369,6 +384,7 @@ def create(
369384
rpc_channel: V1RpcChannel,
370385
mqtt_rpc_channel: V1RpcChannel,
371386
map_rpc_channel: V1RpcChannel,
387+
blob_rpc_channel: V1RpcChannel,
372388
add_dps_listener: Callable[[Callable[[dict[RoborockDataProtocol, Any]], None]], Callable[[], None]],
373389
web_api: UserWebApiClient,
374390
device_cache: DeviceCache,
@@ -383,6 +399,7 @@ def create(
383399
rpc_channel,
384400
mqtt_rpc_channel,
385401
map_rpc_channel,
402+
blob_rpc_channel,
386403
add_dps_listener,
387404
web_api,
388405
device_cache,

roborock/devices/traits/v1/common.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
_LOGGER = logging.getLogger(__name__)
1717

1818

19-
V1ResponseData = dict | list | int | str
19+
V1ResponseData = dict | list | int | str | bytes
2020

2121

2222
class V1TraitDataConverter(ABC):
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Trait for fetching obstacle photos from V1 vacuums."""
2+
3+
from dataclasses import dataclass
4+
5+
from roborock.data import RoborockBase
6+
from roborock.devices.traits.v1 import common
7+
from roborock.exceptions import RoborockException
8+
from roborock.protocols.v1_protocol import V1RpcChannel
9+
from roborock.roborock_typing import RoborockCommand
10+
11+
_PHOTO_TYPE_SMALL = 1
12+
_PHOTO_DATA_BLOCK_TYPE = 3
13+
_MAP_OBJECT_PHOTO_ENABLED_BIT = 10
14+
_TYPE_SIZE = 2
15+
_HEADER_SIZE_SIZE = 2
16+
_PAYLOAD_SIZE_SIZE = 4
17+
_MIN_BLOCK_HEADER_SIZE = _TYPE_SIZE + _HEADER_SIZE_SIZE + _PAYLOAD_SIZE_SIZE
18+
_IMAGE_HEADERS = (b"\x89PNG\r\n\x1a\n", b"\xff\xd8\xff")
19+
20+
21+
@dataclass
22+
class ObstaclePhoto(RoborockBase):
23+
"""Obstacle photo content."""
24+
25+
photo_id: str
26+
image_content: bytes
27+
28+
29+
class ObstaclePhotoConverter(common.V1TraitDataConverter):
30+
"""Convert a decrypted get_photo payload to an obstacle photo."""
31+
32+
def convert(self, response: common.V1ResponseData) -> ObstaclePhoto:
33+
"""Parse the response from the device into an obstacle photo."""
34+
if not isinstance(response, bytes):
35+
raise ValueError(f"Unexpected ObstaclePhotoTrait response format: {type(response)}")
36+
return ObstaclePhoto(photo_id="", image_content=parse_photo_data(response))
37+
38+
39+
def parse_photo_data(response: bytes) -> bytes:
40+
"""Parse the get_photo response payload and return image bytes.
41+
42+
Roborock's app parses get_photo as a sequence of little-endian typed blocks.
43+
Block type 3 contains the image bytes.
44+
"""
45+
offset = 0
46+
while offset + _MIN_BLOCK_HEADER_SIZE <= len(response):
47+
block_type = int.from_bytes(response[offset : offset + _TYPE_SIZE], "little")
48+
header_size = int.from_bytes(
49+
response[offset + _TYPE_SIZE : offset + _TYPE_SIZE + _HEADER_SIZE_SIZE],
50+
"little",
51+
)
52+
payload_size = int.from_bytes(
53+
response[offset + _TYPE_SIZE + _HEADER_SIZE_SIZE : offset + _MIN_BLOCK_HEADER_SIZE],
54+
"little",
55+
)
56+
next_offset = offset + header_size + payload_size
57+
if header_size < _MIN_BLOCK_HEADER_SIZE or next_offset > len(response):
58+
raise RoborockException("Invalid obstacle photo payload")
59+
60+
if block_type == _PHOTO_DATA_BLOCK_TYPE:
61+
image_content = response[offset + header_size : next_offset]
62+
if not image_content.startswith(_IMAGE_HEADERS):
63+
raise RoborockException("Obstacle photo payload is not a supported image")
64+
return image_content
65+
66+
offset = next_offset
67+
68+
raise RoborockException("Obstacle photo payload does not contain photo data")
69+
70+
71+
class ObstaclePhotoTrait(RoborockBase, common.V1TraitMixin):
72+
"""Trait for fetching obstacle photos."""
73+
74+
command = RoborockCommand.GET_PHOTO
75+
converter = ObstaclePhotoConverter()
76+
blob_rpc_channel = True
77+
requires_feature = "is_ai_recognition_obstacle_supported"
78+
79+
def __init__(self, standard_rpc_channel: V1RpcChannel) -> None:
80+
"""Initialize the obstacle photo trait."""
81+
super().__init__()
82+
self._standard_rpc_channel = standard_rpc_channel
83+
84+
async def get_enabled(self) -> bool:
85+
"""Return whether map object photo capture is enabled on the vacuum."""
86+
response = await self._standard_rpc_channel.send_command(RoborockCommand.GET_CAMERA_STATUS)
87+
if not isinstance(response, list) or not response or not isinstance(response[0], int):
88+
raise RoborockException("get_camera_status response did not contain camera status")
89+
return bool((response[0] >> _MAP_OBJECT_PHOTO_ENABLED_BIT) & 1)
90+
91+
async def get_photo(self, photo_id: str) -> ObstaclePhoto:
92+
"""Fetch the small obstacle photo for a map photo ID.
93+
94+
Photo IDs are available as ``Obstacle.details.photo_name`` in
95+
``ParsedMapData.map_data.obstacles_with_photo`` and
96+
``ParsedMapData.map_data.ignored_obstacles_with_photo``.
97+
"""
98+
response = await self.rpc_channel.send_command(
99+
self.command,
100+
params={"img_id": photo_id, "type": _PHOTO_TYPE_SMALL},
101+
)
102+
photo = self.converter.convert(response)
103+
photo.photo_id = photo_id
104+
return photo

roborock/protocols/v1_protocol.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
from __future__ import annotations
44

55
import base64
6+
import gzip
67
import json
78
import logging
89
import secrets
910
import struct
11+
import zlib
1012
from collections.abc import Callable
1113
from dataclasses import dataclass, field
1214
from enum import StrEnum
@@ -24,6 +26,7 @@
2426
__all__ = [
2527
"SecurityData",
2628
"create_security_data",
29+
"create_blob_response_decoder",
2730
"decode_data_protocol_message",
2831
"decode_rpc_response",
2932
"V1RpcChannel",
@@ -261,6 +264,48 @@ class MapResponse:
261264
"""The map data, decrypted and decompressed."""
262265

263266

267+
@dataclass
268+
class BlobResponse:
269+
"""Data structure for V1 blob responses."""
270+
271+
request_id: int
272+
"""The request ID of the blob response."""
273+
274+
data: bytes
275+
"""The blob data, decompressed."""
276+
277+
278+
def create_blob_response_decoder() -> Callable[[RoborockMessage], BlobResponse | None]:
279+
"""Create a decoder for V1 blob response messages.
280+
281+
Obstacle photos are acknowledged through the normal RPC response with
282+
``["ok"]`` and delivered later as a protocol-301 blob frame. The frame starts
283+
with ``ROBOROCK``, stores the RPC request id at bytes 8-11, the header size
284+
at bytes 16-17, and the gzip payload length at bytes 20-23.
285+
"""
286+
287+
def _decode_blob_response(message: RoborockMessage) -> BlobResponse | None:
288+
"""Decode a V1 blob response message."""
289+
if message.protocol != RoborockMessageProtocol.MAP_RESPONSE:
290+
return None
291+
payload = message.payload
292+
if not payload or not payload.startswith(b"ROBOROCK") or len(payload) < 24:
293+
return None
294+
request_id = int.from_bytes(payload[8:12], "little")
295+
header_size = int.from_bytes(payload[16:18], "little")
296+
payload_size = int.from_bytes(payload[20:24], "little")
297+
end_offset = header_size + payload_size
298+
if header_size < 24 or end_offset > len(payload):
299+
raise RoborockException("Invalid V1 blob response format")
300+
try:
301+
data = Utils.decompress(payload[header_size:end_offset])
302+
except (gzip.BadGzipFile, EOFError, zlib.error) as err:
303+
raise RoborockException("Failed to decode blob message payload") from err
304+
return BlobResponse(request_id=request_id, data=data)
305+
306+
return _decode_blob_response
307+
308+
264309
def create_map_response_decoder(security_data: SecurityData) -> Callable[[RoborockMessage], MapResponse | None]:
265310
"""Create a decoder for V1 map response messages."""
266311

roborock/roborock_typing.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ class RoborockCommand(str, Enum):
117117
GET_NETWORK_INFO = "get_network_info"
118118
GET_OFFLINE_MAP_STATUS = "get_offline_map_status"
119119
GET_PERSIST = "get_persist_map"
120+
GET_PHOTO = "get_photo"
120121
GET_PROP = "get_prop"
121122
GET_RANDOM_PKEY = "get_random_pkey"
122123
GET_RECOVER_MAP = "get_recover_map"

0 commit comments

Comments
 (0)