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
Copilot
Luke Lashley
parent
1128d40ee8
commit
0531b6d120
@@ -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