Files
2ee83993b7 Fix rotation conflicts between multiple Roborock devices (#40)
* 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>
2026-06-01 13:15:48 -04:00

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,
}