diff --git a/README.md b/README.md index b231970..4139f3f 100644 --- a/README.md +++ b/README.md @@ -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: `No rotation`, `Rotate 90°`, `Rotate 180°`, `Rotate 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 diff --git a/custom_components/roborock_custom_map/__init__.py b/custom_components/roborock_custom_map/__init__.py index bc1742c..f7ef75a 100644 --- a/custom_components/roborock_custom_map/__init__.py +++ b/custom_components/roborock_custom_map/__init__.py @@ -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,25 @@ 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 +43,7 @@ async def unload_this_entry(): 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 \ No newline at end of file diff --git a/custom_components/roborock_custom_map/const.py b/custom_components/roborock_custom_map/const.py index 8cdbb6f..f9bdcde 100644 --- a/custom_components/roborock_custom_map/const.py +++ b/custom_components/roborock_custom_map/const.py @@ -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" diff --git a/custom_components/roborock_custom_map/image.py b/custom_components/roborock_custom_map/image.py index 2928793..051dd0c 100644 --- a/custom_components/roborock_custom_map/image.py +++ b/custom_components/roborock_custom_map/image.py @@ -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 @@ def __init__( """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 @@ def _map_content(self) -> MapContent | 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.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, diff --git a/custom_components/roborock_custom_map/select.py b/custom_components/roborock_custom_map/select.py new file mode 100644 index 0000000..e383e53 --- /dev/null +++ b/custom_components/roborock_custom_map/select.py @@ -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() diff --git a/custom_components/roborock_custom_map/translations/de.json b/custom_components/roborock_custom_map/translations/de.json new file mode 100644 index 0000000..fe3fd1c --- /dev/null +++ b/custom_components/roborock_custom_map/translations/de.json @@ -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" + } + } + } + } +} diff --git a/custom_components/roborock_custom_map/translations/en.json b/custom_components/roborock_custom_map/translations/en.json index 9ef6414..c65dfd3 100644 --- a/custom_components/roborock_custom_map/translations/en.json +++ b/custom_components/roborock_custom_map/translations/en.json @@ -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°" + } + } + } } }