mirror of
https://github.com/Lash-L/RoborockCustomMap.git
synced 2026-07-26 16:18:32 +02:00
* Add map rotation constants Introduce rotation configuration constants for map image handling. Adds rotation options (0, 90, 180, 270) and dispatcher signal name. * Add per-map rotation select entity Add SelectEntity to control map rotation per map_flag. Rotation value is persisted via RestoreEntity and stored in hass.data. Dispatcher signal notifies image entities when rotation changes. * Enable rotation select platform and initialize storage Register SELECT platform and initialize rotation storage in hass.data. Add proper unload cleanup and reload behavior. * Add backend map rotation with executor offloading Implement backend image rotation using Pillow. Rotation is applied in async_add_executor_job to avoid blocking the event loop. Includes defensive validation and fallback handling. * Add translations for rotation select entity Add English and German translations for map rotation select entity. Includes user-friendly labels for rotation options. * Document map rotation select entity in README Add documentation for the per-map rotation select entity. Explains: - How to rotate maps (0/90/180/270) - Where to find the rotation select entity - That calibration points are rotated as well - That no reload is required Also clarifies usage with Xiaomi Vacuum Map Card. * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Store rotation state per device and map instead of only map_flag. This prevents multiple Roborock devices with identical map_flag values from sharing rotation state and dispatcher signals. * Fix image rotation lookup for multiple Roborock devices * Fix thread-safe state update on rotation change I found and fixed a thread-safety issue in the rotation update signal. The image entity now schedules the state update back onto the Home Assistant event loop before calling `async_write_ha_state()`, instead of calling it directly from the dispatcher callback. --------- Co-authored-by: Luke Lashley <conway220@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
281 lines
9.3 KiB
Python
281 lines
9.3 KiB
Python
"""Support for Roborock image."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
import io
|
|
import logging
|
|
|
|
from PIL import Image, UnidentifiedImageError
|
|
from roborock.devices.traits.v1.home import HomeTrait
|
|
from roborock.devices.traits.v1.map_content import MapContent
|
|
|
|
from homeassistant.components.image import ImageEntity
|
|
from homeassistant.components.roborock.coordinator import RoborockDataUpdateCoordinator
|
|
from homeassistant.components.roborock.entity import RoborockCoordinatedEntityV1
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.const import EntityCategory
|
|
from homeassistant.core import HomeAssistant, callback
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|
from homeassistant.util import dt as dt_util
|
|
|
|
from .const import (
|
|
CONF_MAP_ROTATION,
|
|
DEFAULT_MAP_ROTATION,
|
|
DOMAIN,
|
|
MAP_ROTATION_OPTIONS,
|
|
SIGNAL_ROTATION_CHANGED,
|
|
)
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
PARALLEL_UPDATES = 0
|
|
|
|
|
|
def _png_dimensions(data: bytes) -> tuple[int, int] | None:
|
|
"""Return PNG (width, height) from raw bytes, or None if not a PNG."""
|
|
if len(data) < 24:
|
|
return None
|
|
if data[:8] != b"\x89PNG\r\n\x1a\n":
|
|
return None
|
|
width = int.from_bytes(data[16:20], "big")
|
|
height = int.from_bytes(data[20:24], "big")
|
|
if width <= 0 or height <= 0:
|
|
return None
|
|
return (width, height)
|
|
|
|
|
|
def _rotate_point_map_xy(
|
|
x: float, y: float, w: int, h: int, rotation: int
|
|
) -> tuple[float, float]:
|
|
"""Rotate a point in map pixel space around the image bounds.
|
|
|
|
rotation is counter-clockwise (PIL Image.rotate does CCW).
|
|
Uses continuous coordinates (w - x / h - y) to avoid off-by-one issues.
|
|
"""
|
|
if rotation == 0:
|
|
return (x, y)
|
|
if rotation == 90:
|
|
# CCW 90: new size (h, w)
|
|
return (y, w - x)
|
|
if rotation == 180:
|
|
return (w - x, h - y)
|
|
if rotation == 270:
|
|
# CCW 270 == CW 90: new size (h, w)
|
|
return (h - y, x)
|
|
return (x, y)
|
|
|
|
|
|
async def async_setup_entry(
|
|
hass: HomeAssistant,
|
|
config_entry: ConfigEntry,
|
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
|
) -> None:
|
|
"""Set up Roborock image platform."""
|
|
async_add_entities(
|
|
RoborockMap(
|
|
config_entry,
|
|
f"{coord.duid_slug}_custom_map_{map_info.name or f'Map {map_info.map_flag}'}",
|
|
coord,
|
|
coord.properties_api.home,
|
|
map_info.map_flag,
|
|
map_info.name,
|
|
)
|
|
for coord in config_entry.runtime_data
|
|
if coord.properties_api.home is not None
|
|
for map_info in (coord.properties_api.home.home_map_info or {}).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,
|
|
home_trait: HomeTrait,
|
|
map_flag: int,
|
|
map_name: str,
|
|
) -> None:
|
|
"""Initialize a Roborock map."""
|
|
RoborockCoordinatedEntityV1.__init__(self, unique_id, coordinator)
|
|
ImageEntity.__init__(self, coordinator.hass)
|
|
|
|
self.config_entry = config_entry
|
|
self.map_flag = map_flag
|
|
self.rotation_key = f"{coordinator.duid_slug}_{map_flag}"
|
|
self._home_trait = home_trait
|
|
|
|
if not map_name:
|
|
map_name = f"Map {map_flag}"
|
|
self._attr_name = f"{map_name}_custom"
|
|
|
|
self.cached_map = b""
|
|
self._raw_image_size: tuple[int, int] | None = None
|
|
|
|
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.properties_api.maps.current_map
|
|
|
|
@property
|
|
def _map_content(self) -> MapContent | None:
|
|
if self._home_trait.home_map_content and (
|
|
map_content := self._home_trait.home_map_content.get(self.map_flag)
|
|
):
|
|
return map_content
|
|
return None
|
|
|
|
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()
|
|
|
|
self._attr_image_last_updated = self.coordinator.last_home_update
|
|
|
|
# Listen for rotation changes from the Select entity
|
|
self.async_on_remove(
|
|
async_dispatcher_connect(
|
|
self.hass,
|
|
f"{SIGNAL_ROTATION_CHANGED}_{self.config_entry.entry_id}_{self.rotation_key}",
|
|
self._handle_rotation_changed,
|
|
)
|
|
)
|
|
|
|
self.async_write_ha_state()
|
|
|
|
def _handle_rotation_changed(self) -> None:
|
|
"""Rotation changed; schedule state update in the event loop."""
|
|
self.hass.loop.call_soon_threadsafe(self._async_handle_rotation_changed)
|
|
|
|
|
|
@callback
|
|
def _async_handle_rotation_changed(self) -> None:
|
|
"""Rotation changed; bump last_updated to bust the image cache."""
|
|
self._attr_image_last_updated = dt_util.utcnow()
|
|
self.async_write_ha_state()
|
|
|
|
def _handle_coordinator_update(self) -> None:
|
|
"""Handle coordinator update."""
|
|
if (map_content := self._map_content) is None:
|
|
return
|
|
|
|
if self.cached_map != map_content.image_content:
|
|
self.cached_map = map_content.image_content
|
|
self._raw_image_size = _png_dimensions(self.cached_map)
|
|
self._attr_image_last_updated = self.coordinator.last_home_update
|
|
|
|
super()._handle_coordinator_update()
|
|
|
|
def _rotate_image(self, raw: bytes, rotation: int) -> bytes:
|
|
"""Rotate image in executor thread."""
|
|
img = Image.open(io.BytesIO(raw))
|
|
img = img.rotate(rotation, expand=True)
|
|
|
|
out = io.BytesIO()
|
|
img.save(out, format="PNG")
|
|
return out.getvalue()
|
|
|
|
def _get_rotation(self) -> int:
|
|
"""Get configured rotation for this map from hass.data (set by select entity)."""
|
|
rotation = (
|
|
self.hass.data.get(DOMAIN, {})
|
|
.get(self.config_entry.entry_id, {})
|
|
.get(CONF_MAP_ROTATION, {})
|
|
.get(self.rotation_key, DEFAULT_MAP_ROTATION)
|
|
)
|
|
|
|
if rotation not in MAP_ROTATION_OPTIONS:
|
|
_LOGGER.debug(
|
|
"Unsupported map rotation %s, allowed values: %s, falling back to %s",
|
|
rotation,
|
|
MAP_ROTATION_OPTIONS,
|
|
DEFAULT_MAP_ROTATION,
|
|
)
|
|
return DEFAULT_MAP_ROTATION
|
|
|
|
return rotation
|
|
|
|
async def async_image(self) -> bytes | None:
|
|
"""Get the image (with optional rotation)."""
|
|
if (map_content := self._map_content) is None:
|
|
raise HomeAssistantError("Map flag not found in coordinator maps")
|
|
|
|
raw = map_content.image_content
|
|
rotation = self._get_rotation()
|
|
|
|
if rotation == DEFAULT_MAP_ROTATION:
|
|
return raw
|
|
|
|
try:
|
|
return await self.hass.async_add_executor_job(
|
|
self._rotate_image, raw, rotation
|
|
)
|
|
except (OSError, UnidentifiedImageError) as err:
|
|
_LOGGER.debug(
|
|
"Failed to rotate Roborock map image: %s, returning original image",
|
|
err,
|
|
)
|
|
return raw
|
|
|
|
@property
|
|
def extra_state_attributes(self):
|
|
"""Return extra attributes for map card usage (rotation-aware calibration)."""
|
|
if (map_content := self._map_content) is None:
|
|
raise HomeAssistantError("Map flag not found in coordinator maps")
|
|
|
|
map_data = map_content.map_data
|
|
if map_data is None:
|
|
return {}
|
|
|
|
# Attach room names (same behavior as before)
|
|
if map_data.rooms is not None:
|
|
for room in map_data.rooms.values():
|
|
name = self._home_trait._rooms_trait.room_map.get(room.number)
|
|
room.name = name.name if name else "Unknown"
|
|
|
|
calibration = map_data.calibration()
|
|
|
|
# Rotate ONLY the "map" (pixel-space) side of calibration points.
|
|
# Rooms/zones are in vacuum coordinate space and are mapped via calibration.
|
|
rotation = self._get_rotation()
|
|
size = self._raw_image_size
|
|
if rotation != DEFAULT_MAP_ROTATION and size is not None:
|
|
w, h = size
|
|
rotated_calibration = []
|
|
for pt in calibration:
|
|
mp = pt.get("map") or {}
|
|
x = mp.get("x")
|
|
y = mp.get("y")
|
|
|
|
# If missing/invalid, keep point as-is
|
|
if x is None or y is None:
|
|
rotated_calibration.append(pt)
|
|
continue
|
|
|
|
nx, ny = _rotate_point_map_xy(float(x), float(y), w, h, rotation)
|
|
|
|
new_pt = dict(pt)
|
|
new_map = dict(mp)
|
|
new_map["x"] = nx
|
|
new_map["y"] = ny
|
|
new_pt["map"] = new_map
|
|
rotated_calibration.append(new_pt)
|
|
|
|
calibration = rotated_calibration
|
|
|
|
return {
|
|
"calibration_points": calibration,
|
|
"rooms": map_data.rooms,
|
|
"zones": map_data.zones,
|
|
}
|