core/homeassistant/components/roborock/image.py

194 lines
6.9 KiB
Python

"""Support for Roborock image."""
import asyncio
from collections.abc import Callable
from datetime import datetime
import io
from roborock import RoborockCommand
from vacuum_map_parser_base.config.color import ColorsPalette
from vacuum_map_parser_base.config.image_config import ImageConfig
from vacuum_map_parser_base.config.size import Sizes
from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser
from homeassistant.components.image import ImageEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import (
DEFAULT_DRAWABLES,
DOMAIN,
DRAWABLES,
IMAGE_CACHE_INTERVAL,
MAP_FILE_FORMAT,
MAP_SLEEP,
)
from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator
from .entity import RoborockCoordinatedEntityV1
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RoborockConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Roborock image platform."""
drawables = [
drawable
for drawable, default_value in DEFAULT_DRAWABLES.items()
if config_entry.options.get(DRAWABLES, {}).get(drawable, default_value)
]
parser = RoborockMapDataParser(
ColorsPalette(), Sizes(), drawables, ImageConfig(), []
)
def parse_image(map_bytes: bytes) -> bytes | None:
parsed_map = parser.parse(map_bytes)
if parsed_map.image is None:
return None
img_byte_arr = io.BytesIO()
parsed_map.image.data.save(img_byte_arr, format=MAP_FILE_FORMAT)
return img_byte_arr.getvalue()
await asyncio.gather(
*(refresh_coordinators(hass, coord) for coord in config_entry.runtime_data.v1)
)
async_add_entities(
(
RoborockMap(
config_entry,
f"{coord.duid_slug}_map_{map_info.name}",
coord,
map_info.flag,
map_info.name,
parse_image,
)
for coord in config_entry.runtime_data.v1
for map_info in coord.maps.values()
),
)
class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity):
"""A class to let you visualize the map."""
_attr_has_entity_name = True
image_last_updated: datetime
_attr_name: str
def __init__(
self,
config_entry: ConfigEntry,
unique_id: str,
coordinator: RoborockDataUpdateCoordinator,
map_flag: int,
map_name: str,
parser: Callable[[bytes], bytes | None],
) -> None:
"""Initialize a Roborock map."""
RoborockCoordinatedEntityV1.__init__(self, unique_id, coordinator)
ImageEntity.__init__(self, coordinator.hass)
self.config_entry = config_entry
self._attr_name = map_name
self.parser = parser
self.map_flag = map_flag
self.cached_map = b""
self._attr_entity_category = EntityCategory.DIAGNOSTIC
@property
def is_selected(self) -> bool:
"""Return if this map is the currently selected map."""
return self.map_flag == self.coordinator.current_map
def is_map_valid(self) -> bool:
"""Update the map if it is valid.
Update this map if it is the currently active map, and the
vacuum is cleaning, or if it has never been set at all.
"""
return self.cached_map == b"" or (
self.is_selected
and self.image_last_updated is not None
and self.coordinator.roborock_device_info.props.status is not None
and bool(self.coordinator.roborock_device_info.props.status.in_cleaning)
)
async def async_added_to_hass(self) -> None:
"""When entity is added to hass load any previously cached maps from disk."""
await super().async_added_to_hass()
content = await self.coordinator.map_storage.async_load_map(self.map_flag)
self.cached_map = content or b""
self._attr_image_last_updated = dt_util.utcnow()
self.async_write_ha_state()
def _handle_coordinator_update(self) -> None:
# Bump last updated every third time the coordinator runs, so that async_image
# will be called and we will evaluate on the new coordinator data if we should
# update the cache.
if (
dt_util.utcnow() - self.image_last_updated
).total_seconds() > IMAGE_CACHE_INTERVAL and self.is_map_valid():
self._attr_image_last_updated = dt_util.utcnow()
super()._handle_coordinator_update()
async def async_image(self) -> bytes | None:
"""Update the image if it is not cached."""
if self.is_map_valid():
response = await asyncio.gather(
*(
self.cloud_api.get_map_v1(),
self.coordinator.set_current_map_rooms(),
),
return_exceptions=True,
)
if (
not isinstance(response[0], bytes)
or (content := self.parser(response[0])) is None
):
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="map_failure",
)
if self.cached_map != content:
self.cached_map = content
await self.coordinator.map_storage.async_save_map(
self.map_flag,
content,
)
return self.cached_map
async def refresh_coordinators(
hass: HomeAssistant, coord: RoborockDataUpdateCoordinator
) -> None:
"""Get the starting map information for all maps for this device.
The following steps must be done synchronously.
Only one map can be loaded at a time per device.
"""
cur_map = coord.current_map
# This won't be None at this point as the coordinator will have run first.
assert cur_map is not None
map_flags = sorted(coord.maps, key=lambda data: data == cur_map, reverse=True)
for map_flag in map_flags:
if map_flag != cur_map:
# Only change the map and sleep if we have multiple maps.
await coord.api.send_command(RoborockCommand.LOAD_MULTI_MAP, [map_flag])
coord.current_map = map_flag
# We cannot get the map until the roborock servers fully process the
# map change.
await asyncio.sleep(MAP_SLEEP)
await coord.set_current_map_rooms()
if len(coord.maps) != 1:
# Set the map back to the map the user previously had selected so that it
# does not change the end user's app.
# Only needs to happen when we changed maps above.
await coord.cloud_api.send_command(RoborockCommand.LOAD_MULTI_MAP, [cur_map])
coord.current_map = cur_map