Skip to content

Commit 0531b6d

Browse files
TheHangMan97Lash-LCopilot
authored
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>
1 parent 1128d40 commit 0531b6d

7 files changed

Lines changed: 348 additions & 20 deletions

File tree

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,26 @@ map_source:
2626
calibration_source:
2727
camera: true
2828
```
29+
### Map rotation (new)
30+
31+
If your map is displayed sideways or upside down, you can rotate the map directly in Home Assistant.
32+
33+
This integration provides a **Select entity per map** to control rotation:
34+
- `select.<...>_rotation`
35+
- Options: ``, `90°`, `180°`, `270°` (labels depend on your HA language)
36+
37+
This rotates **both**:
38+
- the map image
39+
- and the calibration points used by the Xiaomi Vacuum Map Card
40+
(so rooms/zones and interactions stay aligned after rotation)
41+
42+
**How to use**
43+
1. Go to **Settings → Devices & services → Roborock Custom Map**
44+
2. Open the device/entities list
45+
3. Find the `… rotation` select entity for your map and choose the correct rotation
46+
47+
No reload is required; the map updates immediately.
48+
2949
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'
3050

3151
### Installation

custom_components/roborock_custom_map/__init__.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,47 @@
22

33
from __future__ import annotations
44

5-
from homeassistant.config_entries import ConfigEntry
5+
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
66
from homeassistant.const import Platform
7-
from homeassistant.core import HomeAssistant
8-
from homeassistant.config_entries import ConfigEntryState
7+
from homeassistant.core import HomeAssistant, callback
98
from homeassistant.exceptions import ConfigEntryNotReady
109

11-
PLATFORMS = [Platform.IMAGE]
10+
from .const import CONF_MAP_ROTATION, DOMAIN
11+
12+
PLATFORMS = [Platform.IMAGE, Platform.SELECT]
1213

1314

1415
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
1516
"""Set up Roborock Custom map from a config entry."""
1617
roborock_entries = hass.config_entries.async_entries("roborock")
1718
coordinators = []
1819

19-
async def unload_this_entry():
20-
await hass.config_entries.async_reload(entry.entry_id)
20+
@callback
21+
def unload_this_entry() -> None:
22+
hass.async_create_task(hass.config_entries.async_reload(entry.entry_id))
2123

2224
for r_entry in roborock_entries:
2325
if r_entry.state == ConfigEntryState.LOADED:
2426
coordinators.extend(r_entry.runtime_data.v1)
25-
# If any unload, then we should reload as well in case there are major changes.
2627
r_entry.async_on_unload(unload_this_entry)
27-
if len(coordinators) == 0:
28+
29+
if not coordinators:
2830
raise ConfigEntryNotReady("No Roborock entries loaded. Cannot start.")
31+
2932
entry.runtime_data = coordinators
33+
34+
hass.data.setdefault(DOMAIN, {})
35+
hass.data[DOMAIN].setdefault(entry.entry_id, {})
36+
hass.data[DOMAIN][entry.entry_id].setdefault(CONF_MAP_ROTATION, {})
37+
3038
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
3139

3240
return True
3341

3442

3543
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
3644
"""Unload a config entry."""
37-
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
45+
unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
46+
if unloaded:
47+
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
48+
return unloaded
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
"""Constants for Roborock Custom Map integration."""
22

33
DOMAIN = "roborock_custom_map"
4+
5+
CONF_MAP_ROTATION = "map_rotation"
6+
DEFAULT_MAP_ROTATION = 0
7+
MAP_ROTATION_OPTIONS = (0, 90, 180, 270)
8+
9+
SIGNAL_ROTATION_CHANGED = "roborock_custom_map_rotation_changed"

custom_components/roborock_custom_map/image.py

Lines changed: 159 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,79 @@
11
"""Support for Roborock image."""
22

3+
from __future__ import annotations
4+
35
from datetime import datetime
6+
import io
47
import logging
58

9+
from PIL import Image, UnidentifiedImageError
10+
from roborock.devices.traits.v1.home import HomeTrait
11+
from roborock.devices.traits.v1.map_content import MapContent
12+
613
from homeassistant.components.image import ImageEntity
714
from homeassistant.components.roborock.coordinator import RoborockDataUpdateCoordinator
815
from homeassistant.components.roborock.entity import RoborockCoordinatedEntityV1
916
from homeassistant.config_entries import ConfigEntry
1017
from homeassistant.const import EntityCategory
1118
from homeassistant.core import HomeAssistant
12-
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
13-
from roborock.devices.traits.v1.home import HomeTrait
14-
from roborock.devices.traits.v1.map_content import MapContent
1519
from homeassistant.exceptions import HomeAssistantError
20+
from homeassistant.helpers.dispatcher import async_dispatcher_connect
21+
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
22+
from homeassistant.util import dt as dt_util
23+
24+
from .const import (
25+
CONF_MAP_ROTATION,
26+
DEFAULT_MAP_ROTATION,
27+
DOMAIN,
28+
MAP_ROTATION_OPTIONS,
29+
SIGNAL_ROTATION_CHANGED,
30+
)
1631

1732
_LOGGER = logging.getLogger(__name__)
1833

1934
PARALLEL_UPDATES = 0
2035

2136

37+
def _png_dimensions(data: bytes) -> tuple[int, int] | None:
38+
"""Return PNG (width, height) from raw bytes, or None if not a PNG."""
39+
if len(data) < 24:
40+
return None
41+
if data[:8] != b"\x89PNG\r\n\x1a\n":
42+
return None
43+
width = int.from_bytes(data[16:20], "big")
44+
height = int.from_bytes(data[20:24], "big")
45+
if width <= 0 or height <= 0:
46+
return None
47+
return (width, height)
48+
49+
50+
def _rotate_point_map_xy(
51+
x: float, y: float, w: int, h: int, rotation: int
52+
) -> tuple[float, float]:
53+
"""Rotate a point in map pixel space around the image bounds.
54+
55+
rotation is counter-clockwise (PIL Image.rotate does CCW).
56+
Uses continuous coordinates (w - x / h - y) to avoid off-by-one issues.
57+
"""
58+
if rotation == 0:
59+
return (x, y)
60+
if rotation == 90:
61+
# CCW 90: new size (h, w)
62+
return (y, w - x)
63+
if rotation == 180:
64+
return (w - x, h - y)
65+
if rotation == 270:
66+
# CCW 270 == CW 90: new size (h, w)
67+
return (h - y, x)
68+
return (x, y)
69+
70+
2271
async def async_setup_entry(
2372
hass: HomeAssistant,
24-
config_entry,
73+
config_entry: ConfigEntry,
2574
async_add_entities: AddConfigEntryEntitiesCallback,
2675
) -> None:
2776
"""Set up Roborock image platform."""
28-
2977
async_add_entities(
3078
RoborockMap(
3179
config_entry,
@@ -60,14 +108,18 @@ def __init__(
60108
"""Initialize a Roborock map."""
61109
RoborockCoordinatedEntityV1.__init__(self, unique_id, coordinator)
62110
ImageEntity.__init__(self, coordinator.hass)
111+
63112
self.config_entry = config_entry
64-
if not map_name:
65-
map_name = f"Map {map_flag}"
66-
self._attr_name = map_name + "_custom"
67113
self.map_flag = map_flag
68114
self._home_trait = home_trait
69115

116+
if not map_name:
117+
map_name = f"Map {map_flag}"
118+
self._attr_name = f"{map_name}_custom"
119+
70120
self.cached_map = b""
121+
self._raw_image_size: tuple[int, int] | None = None
122+
71123
self._attr_entity_category = EntityCategory.DIAGNOSTIC
72124

73125
@property
@@ -86,38 +138,134 @@ def _map_content(self) -> MapContent | None:
86138
async def async_added_to_hass(self) -> None:
87139
"""When entity is added to hass load any previously cached maps from disk."""
88140
await super().async_added_to_hass()
141+
89142
self._attr_image_last_updated = self.coordinator.last_home_update
143+
144+
# Listen for rotation changes from the Select entity
145+
self.async_on_remove(
146+
async_dispatcher_connect(
147+
self.hass,
148+
f"{SIGNAL_ROTATION_CHANGED}_{self.config_entry.entry_id}_{self.map_flag}",
149+
self._handle_rotation_changed,
150+
)
151+
)
152+
153+
self.async_write_ha_state()
154+
155+
def _handle_rotation_changed(self) -> None:
156+
"""Rotation changed; bump last_updated to bust the image cache."""
157+
self._attr_image_last_updated = dt_util.utcnow()
90158
self.async_write_ha_state()
91159

92160
def _handle_coordinator_update(self) -> None:
93-
# If the coordinator has updated the map, we can update the image.
161+
"""Handle coordinator update."""
94162
if (map_content := self._map_content) is None:
95163
return
164+
96165
if self.cached_map != map_content.image_content:
97166
self.cached_map = map_content.image_content
167+
self._raw_image_size = _png_dimensions(self.cached_map)
98168
self._attr_image_last_updated = self.coordinator.last_home_update
99169

100170
super()._handle_coordinator_update()
101171

172+
def _rotate_image(self, raw: bytes, rotation: int) -> bytes:
173+
"""Rotate image in executor thread."""
174+
img = Image.open(io.BytesIO(raw))
175+
img = img.rotate(rotation, expand=True)
176+
177+
out = io.BytesIO()
178+
img.save(out, format="PNG")
179+
return out.getvalue()
180+
181+
def _get_rotation(self) -> int:
182+
"""Get configured rotation for this map from hass.data (set by select entity)."""
183+
rotation = (
184+
self.hass.data.get(DOMAIN, {})
185+
.get(self.config_entry.entry_id, {})
186+
.get(CONF_MAP_ROTATION, {})
187+
.get(self.map_flag, DEFAULT_MAP_ROTATION)
188+
)
189+
190+
if rotation not in MAP_ROTATION_OPTIONS:
191+
_LOGGER.debug(
192+
"Unsupported map rotation %s, allowed values: %s, falling back to %s",
193+
rotation,
194+
MAP_ROTATION_OPTIONS,
195+
DEFAULT_MAP_ROTATION,
196+
)
197+
return DEFAULT_MAP_ROTATION
198+
199+
return rotation
200+
102201
async def async_image(self) -> bytes | None:
103-
"""Get the cached image."""
202+
"""Get the image (with optional rotation)."""
104203
if (map_content := self._map_content) is None:
105204
raise HomeAssistantError("Map flag not found in coordinator maps")
106-
return map_content.image_content
205+
206+
raw = map_content.image_content
207+
rotation = self._get_rotation()
208+
209+
if rotation == DEFAULT_MAP_ROTATION:
210+
return raw
211+
212+
try:
213+
return await self.hass.async_add_executor_job(
214+
self._rotate_image, raw, rotation
215+
)
216+
except (OSError, UnidentifiedImageError) as err:
217+
_LOGGER.debug(
218+
"Failed to rotate Roborock map image: %s, returning original image",
219+
err,
220+
)
221+
return raw
107222

108223
@property
109224
def extra_state_attributes(self):
225+
"""Return extra attributes for map card usage (rotation-aware calibration)."""
110226
if (map_content := self._map_content) is None:
111227
raise HomeAssistantError("Map flag not found in coordinator maps")
112228

113229
map_data = map_content.map_data
114230
if map_data is None:
115231
return {}
232+
233+
# Attach room names (same behavior as before)
116234
if map_data.rooms is not None:
117235
for room in map_data.rooms.values():
118236
name = self._home_trait._rooms_trait.room_map.get(room.number)
119237
room.name = name.name if name else "Unknown"
238+
120239
calibration = map_data.calibration()
240+
241+
# Rotate ONLY the "map" (pixel-space) side of calibration points.
242+
# Rooms/zones are in vacuum coordinate space and are mapped via calibration.
243+
rotation = self._get_rotation()
244+
size = self._raw_image_size
245+
if rotation != DEFAULT_MAP_ROTATION and size is not None:
246+
w, h = size
247+
rotated_calibration = []
248+
for pt in calibration:
249+
mp = pt.get("map") or {}
250+
x = mp.get("x")
251+
y = mp.get("y")
252+
253+
# If missing/invalid, keep point as-is
254+
if x is None or y is None:
255+
rotated_calibration.append(pt)
256+
continue
257+
258+
nx, ny = _rotate_point_map_xy(float(x), float(y), w, h, rotation)
259+
260+
new_pt = dict(pt)
261+
new_map = dict(mp)
262+
new_map["x"] = nx
263+
new_map["y"] = ny
264+
new_pt["map"] = new_map
265+
rotated_calibration.append(new_pt)
266+
267+
calibration = rotated_calibration
268+
121269
return {
122270
"calibration_points": calibration,
123271
"rooms": map_data.rooms,

0 commit comments

Comments
 (0)