roborock.devices.traits.v1.obstacle_photos

Trait for fetching obstacle photos from V1 vacuums.

  1"""Trait for fetching obstacle photos from V1 vacuums."""
  2
  3from dataclasses import dataclass
  4
  5from roborock.data import RoborockBase
  6from roborock.devices.traits.v1 import common
  7from roborock.exceptions import RoborockException
  8from roborock.protocols.v1_protocol import V1RpcChannel
  9from 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
 22class ObstaclePhoto(RoborockBase):
 23    """Obstacle photo content."""
 24
 25    photo_id: str
 26    image_content: bytes
 27
 28
 29class 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
 39def 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
 71class 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
@dataclass
class ObstaclePhoto(roborock.data.containers.RoborockBase):
22@dataclass
23class ObstaclePhoto(RoborockBase):
24    """Obstacle photo content."""
25
26    photo_id: str
27    image_content: bytes

Obstacle photo content.

ObstaclePhoto(photo_id: str, image_content: bytes)
photo_id: str
image_content: bytes
class ObstaclePhotoConverter(roborock.devices.traits.v1.common.V1TraitDataConverter):
30class ObstaclePhotoConverter(common.V1TraitDataConverter):
31    """Convert a decrypted get_photo payload to an obstacle photo."""
32
33    def convert(self, response: common.V1ResponseData) -> ObstaclePhoto:
34        """Parse the response from the device into an obstacle photo."""
35        if not isinstance(response, bytes):
36            raise ValueError(f"Unexpected ObstaclePhotoTrait response format: {type(response)}")
37        return ObstaclePhoto(photo_id="", image_content=parse_photo_data(response))

Convert a decrypted get_photo payload to an obstacle photo.

def convert( self, response: dict | list | int | str | bytes) -> ObstaclePhoto:
33    def convert(self, response: common.V1ResponseData) -> ObstaclePhoto:
34        """Parse the response from the device into an obstacle photo."""
35        if not isinstance(response, bytes):
36            raise ValueError(f"Unexpected ObstaclePhotoTrait response format: {type(response)}")
37        return ObstaclePhoto(photo_id="", image_content=parse_photo_data(response))

Parse the response from the device into an obstacle photo.

def parse_photo_data(response: bytes) -> bytes:
40def parse_photo_data(response: bytes) -> bytes:
41    """Parse the get_photo response payload and return image bytes.
42
43    Roborock's app parses get_photo as a sequence of little-endian typed blocks.
44    Block type 3 contains the image bytes.
45    """
46    offset = 0
47    while offset + _MIN_BLOCK_HEADER_SIZE <= len(response):
48        block_type = int.from_bytes(response[offset : offset + _TYPE_SIZE], "little")
49        header_size = int.from_bytes(
50            response[offset + _TYPE_SIZE : offset + _TYPE_SIZE + _HEADER_SIZE_SIZE],
51            "little",
52        )
53        payload_size = int.from_bytes(
54            response[offset + _TYPE_SIZE + _HEADER_SIZE_SIZE : offset + _MIN_BLOCK_HEADER_SIZE],
55            "little",
56        )
57        next_offset = offset + header_size + payload_size
58        if header_size < _MIN_BLOCK_HEADER_SIZE or next_offset > len(response):
59            raise RoborockException("Invalid obstacle photo payload")
60
61        if block_type == _PHOTO_DATA_BLOCK_TYPE:
62            image_content = response[offset + header_size : next_offset]
63            if not image_content.startswith(_IMAGE_HEADERS):
64                raise RoborockException("Obstacle photo payload is not a supported image")
65            return image_content
66
67        offset = next_offset
68
69    raise RoborockException("Obstacle photo payload does not contain photo data")

Parse the get_photo response payload and return image bytes.

Roborock's app parses get_photo as a sequence of little-endian typed blocks. Block type 3 contains the image bytes.

 72class ObstaclePhotoTrait(RoborockBase, common.V1TraitMixin):
 73    """Trait for fetching obstacle photos."""
 74
 75    command = RoborockCommand.GET_PHOTO
 76    converter = ObstaclePhotoConverter()
 77    blob_rpc_channel = True
 78    requires_feature = "is_ai_recognition_obstacle_supported"
 79
 80    def __init__(self, standard_rpc_channel: V1RpcChannel) -> None:
 81        """Initialize the obstacle photo trait."""
 82        super().__init__()
 83        self._standard_rpc_channel = standard_rpc_channel
 84
 85    async def get_enabled(self) -> bool:
 86        """Return whether map object photo capture is enabled on the vacuum."""
 87        response = await self._standard_rpc_channel.send_command(RoborockCommand.GET_CAMERA_STATUS)
 88        if not isinstance(response, list) or not response or not isinstance(response[0], int):
 89            raise RoborockException("get_camera_status response did not contain camera status")
 90        return bool((response[0] >> _MAP_OBJECT_PHOTO_ENABLED_BIT) & 1)
 91
 92    async def get_photo(self, photo_id: str) -> ObstaclePhoto:
 93        """Fetch the small obstacle photo for a map photo ID.
 94
 95        Photo IDs are available as ``Obstacle.details.photo_name`` in
 96        ``ParsedMapData.map_data.obstacles_with_photo`` and
 97        ``ParsedMapData.map_data.ignored_obstacles_with_photo``.
 98        """
 99        response = await self.rpc_channel.send_command(
100            self.command,
101            params={"img_id": photo_id, "type": _PHOTO_TYPE_SMALL},
102        )
103        photo = self.converter.convert(response)
104        photo.photo_id = photo_id
105        return photo

Trait for fetching obstacle photos.

ObstaclePhotoTrait(standard_rpc_channel: roborock.protocols.v1_protocol.V1RpcChannel)
80    def __init__(self, standard_rpc_channel: V1RpcChannel) -> None:
81        """Initialize the obstacle photo trait."""
82        super().__init__()
83        self._standard_rpc_channel = standard_rpc_channel

Initialize the obstacle photo trait.

command = <RoborockCommand.GET_PHOTO: 'get_photo'>

The RoborockCommand used to fetch the trait data from the device (internal only).

converter = ObstaclePhotoConverter

The converter used to parse the response from the device (internal only).

blob_rpc_channel = True
requires_feature = 'is_ai_recognition_obstacle_supported'
async def get_enabled(self) -> bool:
85    async def get_enabled(self) -> bool:
86        """Return whether map object photo capture is enabled on the vacuum."""
87        response = await self._standard_rpc_channel.send_command(RoborockCommand.GET_CAMERA_STATUS)
88        if not isinstance(response, list) or not response or not isinstance(response[0], int):
89            raise RoborockException("get_camera_status response did not contain camera status")
90        return bool((response[0] >> _MAP_OBJECT_PHOTO_ENABLED_BIT) & 1)

Return whether map object photo capture is enabled on the vacuum.

async def get_photo( self, photo_id: str) -> ObstaclePhoto:
 92    async def get_photo(self, photo_id: str) -> ObstaclePhoto:
 93        """Fetch the small obstacle photo for a map photo ID.
 94
 95        Photo IDs are available as ``Obstacle.details.photo_name`` in
 96        ``ParsedMapData.map_data.obstacles_with_photo`` and
 97        ``ParsedMapData.map_data.ignored_obstacles_with_photo``.
 98        """
 99        response = await self.rpc_channel.send_command(
100            self.command,
101            params={"img_id": photo_id, "type": _PHOTO_TYPE_SMALL},
102        )
103        photo = self.converter.convert(response)
104        photo.photo_id = photo_id
105        return photo

Fetch the small obstacle photo for a map photo ID.

Photo IDs are available as Obstacle.details.photo_name in ParsedMapData.map_data.obstacles_with_photo and ParsedMapData.map_data.ignored_obstacles_with_photo.