Add per-map rotation support with select entity and calibrated overlays (#37)

* 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>

---------

Co-authored-by: Luke Lashley <conway220@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Julian Edlinger
2026-04-16 20:37:23 -04:00
committed by GitHub
co-authored by Luke Lashley Copilot
parent 1128d40ee8
commit 0531b6d120
7 changed files with 348 additions and 20 deletions
+20
View File
@@ -26,6 +26,26 @@ map_source:
calibration_source:
camera: true
```
### Map rotation (new)
If your map is displayed sideways or upside down, you can rotate the map directly in Home Assistant.
This integration provides a **Select entity per map** to control rotation:
- `select.<...>_rotation`
- Options: `0°`, `90°`, `180°`, `270°` (labels depend on your HA language)
This rotates **both**:
- the map image
- and the calibration points used by the Xiaomi Vacuum Map Card
(so rooms/zones and interactions stay aligned after rotation)
**How to use**
1. Go to **Settings → Devices & services → Roborock Custom Map**
2. Open the device/entities list
3. Find the `… rotation` select entity for your map and choose the correct rotation
No reload is required; the map updates immediately.
6. You can hit Edit on the card and then Generate Room Configs to allow for cleaning of rooms. It might generate extra keys, so check the yaml and make sure there are no extra 'predefined_sections'
### Installation
@@ -2,13 +2,14 @@
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
PLATFORMS = [Platform.IMAGE]
from .const import CONF_MAP_ROTATION, DOMAIN
PLATFORMS = [Platform.IMAGE, Platform.SELECT]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -16,17 +17,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
roborock_entries = hass.config_entries.async_entries("roborock")
coordinators = []
async def unload_this_entry():
await hass.config_entries.async_reload(entry.entry_id)
@callback
def unload_this_entry() -> None:
hass.async_create_task(hass.config_entries.async_reload(entry.entry_id))
for r_entry in roborock_entries:
if r_entry.state == ConfigEntryState.LOADED:
coordinators.extend(r_entry.runtime_data.v1)
# If any unload, then we should reload as well in case there are major changes.
r_entry.async_on_unload(unload_this_entry)
if len(coordinators) == 0:
if not coordinators:
raise ConfigEntryNotReady("No Roborock entries loaded. Cannot start.")
entry.runtime_data = coordinators
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN].setdefault(entry.entry_id, {})
hass.data[DOMAIN][entry.entry_id].setdefault(CONF_MAP_ROTATION, {})
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -34,4 +42,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unloaded:
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
return unloaded
@@ -1,3 +1,9 @@
"""Constants for Roborock Custom Map integration."""
DOMAIN = "roborock_custom_map"
CONF_MAP_ROTATION = "map_rotation"
DEFAULT_MAP_ROTATION = 0
MAP_ROTATION_OPTIONS = (0, 90, 180, 270)
SIGNAL_ROTATION_CHANGED = "roborock_custom_map_rotation_changed"
+159 -11
View File
@@ -1,31 +1,79 @@
"""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
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from roborock.devices.traits.v1.home import HomeTrait
from roborock.devices.traits.v1.map_content import MapContent
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,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Roborock image platform."""
async_add_entities(
RoborockMap(
config_entry,
@@ -60,14 +108,18 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity):
"""Initialize a Roborock map."""
RoborockCoordinatedEntityV1.__init__(self, unique_id, coordinator)
ImageEntity.__init__(self, coordinator.hass)
self.config_entry = config_entry
if not map_name:
map_name = f"Map {map_flag}"
self._attr_name = map_name + "_custom"
self.map_flag = 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
@@ -86,38 +138,134 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity):
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.map_flag}",
self._handle_rotation_changed,
)
)
self.async_write_ha_state()
def _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:
# If the coordinator has updated the map, we can update the image.
"""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.map_flag, 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 cached image."""
"""Get the image (with optional rotation)."""
if (map_content := self._map_content) is None:
raise HomeAssistantError("Map flag not found in coordinator maps")
return map_content.image_content
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,
@@ -0,0 +1,110 @@
"""Support for Roborock map rotation select entities."""
from __future__ import annotations
import logging
from homeassistant.components.roborock.coordinator import RoborockDataUpdateCoordinator
from homeassistant.components.roborock.entity import RoborockCoordinatedEntityV1
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import (
CONF_MAP_ROTATION,
DEFAULT_MAP_ROTATION,
DOMAIN,
MAP_ROTATION_OPTIONS,
SIGNAL_ROTATION_CHANGED,
)
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up rotation Select entities (one per map)."""
async_add_entities(
RoborockMapRotationSelect(
config_entry=config_entry,
unique_id=f"{coord.duid_slug}_map_rotation_{map_info.map_flag}",
coordinator=coord,
map_flag=map_info.map_flag,
map_name=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 RoborockMapRotationSelect(RoborockCoordinatedEntityV1, RestoreEntity, SelectEntity):
"""Select entity to control map rotation."""
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.CONFIG
def __init__(
self,
config_entry: ConfigEntry,
unique_id: str,
coordinator: RoborockDataUpdateCoordinator,
map_flag: int,
map_name: str,
) -> None:
"""Initialize rotation select."""
RoborockCoordinatedEntityV1.__init__(self, unique_id, coordinator)
self.config_entry = config_entry
self.map_flag = map_flag
if not map_name:
map_name = f"Map {map_flag}"
self._attr_name = f"{map_name} rotation"
self._attr_options = [str(v) for v in MAP_ROTATION_OPTIONS]
self._attr_current_option = str(DEFAULT_MAP_ROTATION)
self._attr_translation_key = "rotation"
async def async_added_to_hass(self) -> None:
"""Restore previous rotation setting and store in hass.data."""
await super().async_added_to_hass()
if (last := await self.async_get_last_state()) is not None:
if last.state in self._attr_options:
self._attr_current_option = last.state
# Persist selection for the image entity to read
self.hass.data[DOMAIN][self.config_entry.entry_id][CONF_MAP_ROTATION][
self.map_flag
] = int(self._attr_current_option)
self.async_write_ha_state()
async def async_select_option(self, option: str) -> None:
"""Handle user selecting a rotation option."""
if option not in self._attr_options:
return
self._attr_current_option = option
self.hass.data[DOMAIN][self.config_entry.entry_id][CONF_MAP_ROTATION][
self.map_flag
] = int(option)
# Notify the image entity to bust the cache via image_last_updated bump
async_dispatcher_send(
self.hass,
f"{SIGNAL_ROTATION_CHANGED}_{self.config_entry.entry_id}_{self.map_flag}",
)
self.async_write_ha_state()
@@ -0,0 +1,20 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
}
},
"entity": {
"select": {
"rotation": {
"name": "Kartenrotation",
"state": {
"0": "Keine Drehung",
"90": "90° drehen",
"180": "180° drehen",
"270": "270° drehen"
}
}
}
}
}
@@ -3,5 +3,18 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
}
},
"entity": {
"select": {
"rotation": {
"name": "Map rotation",
"state": {
"0": "0°",
"90": "90°",
"180": "180°",
"270": "270°"
}
}
}
}
}