mirror of
https://github.com/Lash-L/RoborockCustomMap.git
synced 2026-07-26 16:18:32 +02:00
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:
co-authored by
Luke Lashley
Copilot
parent
1128d40ee8
commit
0531b6d120
@@ -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"
|
||||
|
||||
@@ -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°"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user