From 4b697d898d54984da7a0f736f38716280370bb90 Mon Sep 17 00:00:00 2001 From: TheHangMan97 Date: Sun, 22 Feb 2026 10:24:16 +0100 Subject: [PATCH 1/6] Add map rotation constants Introduce rotation configuration constants for map image handling. Adds rotation options (0, 90, 180, 270) and dispatcher signal name. --- custom_components/roborock_custom_map/const.py | 6 ++++++ 1 file changed, 6 insertions(+) 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" From 1086da5dbbd79b439b15fb4b826c8a5a61af65cb Mon Sep 17 00:00:00 2001 From: TheHangMan97 Date: Sun, 22 Feb 2026 10:24:47 +0100 Subject: [PATCH 2/6] 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. --- .../roborock_custom_map/select.py | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 custom_components/roborock_custom_map/select.py 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() From aac77d3f90deec52c40f72791b8b347ab697376f Mon Sep 17 00:00:00 2001 From: TheHangMan97 Date: Sun, 22 Feb 2026 10:25:24 +0100 Subject: [PATCH 3/6] Enable rotation select platform and initialize storage Register SELECT platform and initialize rotation storage in hass.data. Add proper unload cleanup and reload behavior. --- .../roborock_custom_map/__init__.py | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) 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 From f118aeeb25e50f132eefd12598056d22debb6bf6 Mon Sep 17 00:00:00 2001 From: TheHangMan97 Date: Sun, 22 Feb 2026 10:25:41 +0100 Subject: [PATCH 4/6] 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. --- .../roborock_custom_map/image.py | 170 ++++++++++++++++-- 1 file changed, 159 insertions(+), 11 deletions(-) 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, From b0f120c1acb60e61b7766d259530ef9cab78e6d3 Mon Sep 17 00:00:00 2001 From: TheHangMan97 Date: Sun, 22 Feb 2026 10:26:28 +0100 Subject: [PATCH 5/6] Add translations for rotation select entity Add English and German translations for map rotation select entity. Includes user-friendly labels for rotation options. --- .../roborock_custom_map/translations/de.json | 20 +++++++++++++++++++ .../roborock_custom_map/translations/en.json | 13 ++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 custom_components/roborock_custom_map/translations/de.json 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°" + } + } + } } } From 8b2d3572fcf10d8d0e0d3ad46832682eecc43413 Mon Sep 17 00:00:00 2001 From: TheHangMan97 Date: Sun, 22 Feb 2026 10:31:45 +0100 Subject: [PATCH 6/6] 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. --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) 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