11"""Support for Roborock image."""
22
3+ from __future__ import annotations
4+
35from datetime import datetime
6+ import io
47import 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+
613from homeassistant .components .image import ImageEntity
714from homeassistant .components .roborock .coordinator import RoborockDataUpdateCoordinator
815from homeassistant .components .roborock .entity import RoborockCoordinatedEntityV1
916from homeassistant .config_entries import ConfigEntry
1017from homeassistant .const import EntityCategory
1118from 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
1519from 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
1934PARALLEL_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"\x89 PNG\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+
2271async 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