Fix rotation conflicts between multiple Roborock devices (#40)

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

* Store rotation state per device and map instead of only map_flag.

This prevents multiple Roborock devices with identical map_flag values
from sharing rotation state and dispatcher signals.

* Fix image rotation lookup for multiple Roborock devices

* Fix thread-safe state update on rotation change

I found and fixed a thread-safety issue in the rotation update signal.

The image entity now schedules the state update back onto the Home Assistant event loop before calling `async_write_ha_state()`, instead of calling it directly from the dispatcher callback.

---------

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-06-01 13:15:48 -04:00
committed by GitHub
co-authored by Luke Lashley Copilot
parent 52b4ffec4a
commit 2ee83993b7
2 changed files with 15 additions and 11 deletions
+10 -3
View File
@@ -15,7 +15,7 @@ from homeassistant.components.roborock.coordinator import RoborockDataUpdateCoor
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.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -111,6 +111,7 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity):
self.config_entry = config_entry
self.map_flag = map_flag
self.rotation_key = f"{coordinator.duid_slug}_{map_flag}"
self._home_trait = home_trait
if not map_name:
@@ -145,7 +146,7 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity):
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{SIGNAL_ROTATION_CHANGED}_{self.config_entry.entry_id}_{self.map_flag}",
f"{SIGNAL_ROTATION_CHANGED}_{self.config_entry.entry_id}_{self.rotation_key}",
self._handle_rotation_changed,
)
)
@@ -153,6 +154,12 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity):
self.async_write_ha_state()
def _handle_rotation_changed(self) -> None:
"""Rotation changed; schedule state update in the event loop."""
self.hass.loop.call_soon_threadsafe(self._async_handle_rotation_changed)
@callback
def _async_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()
@@ -184,7 +191,7 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity):
self.hass.data.get(DOMAIN, {})
.get(self.config_entry.entry_id, {})
.get(CONF_MAP_ROTATION, {})
.get(self.map_flag, DEFAULT_MAP_ROTATION)
.get(self.rotation_key, DEFAULT_MAP_ROTATION)
)
if rotation not in MAP_ROTATION_OPTIONS:
@@ -2,8 +2,6 @@
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
@@ -22,8 +20,6 @@ from .const import (
SIGNAL_ROTATION_CHANGED,
)
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
@@ -52,6 +48,7 @@ class RoborockMapRotationSelect(RoborockCoordinatedEntityV1, RestoreEntity, Sele
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "rotation"
def __init__(
self,
@@ -66,6 +63,7 @@ class RoborockMapRotationSelect(RoborockCoordinatedEntityV1, RestoreEntity, Sele
self.config_entry = config_entry
self.map_flag = map_flag
self.rotation_key = f"{coordinator.duid_slug}_{map_flag}"
if not map_name:
map_name = f"Map {map_flag}"
@@ -73,7 +71,6 @@ class RoborockMapRotationSelect(RoborockCoordinatedEntityV1, RestoreEntity, Sele
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."""
@@ -85,7 +82,7 @@ class RoborockMapRotationSelect(RoborockCoordinatedEntityV1, RestoreEntity, Sele
# Persist selection for the image entity to read
self.hass.data[DOMAIN][self.config_entry.entry_id][CONF_MAP_ROTATION][
self.map_flag
self.rotation_key
] = int(self._attr_current_option)
self.async_write_ha_state()
@@ -98,13 +95,13 @@ class RoborockMapRotationSelect(RoborockCoordinatedEntityV1, RestoreEntity, Sele
self._attr_current_option = option
self.hass.data[DOMAIN][self.config_entry.entry_id][CONF_MAP_ROTATION][
self.map_flag
self.rotation_key
] = 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}",
f"{SIGNAL_ROTATION_CHANGED}_{self.config_entry.entry_id}_{self.rotation_key}",
)
self.async_write_ha_state()