Skip to content

Commit f118aee

Browse files
committed
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.
1 parent aac77d3 commit f118aee

1 file changed

Lines changed: 159 additions & 11 deletions

File tree

  • custom_components/roborock_custom_map

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)