22
33from __future__ import annotations
44
5- from datetime import datetime
5+ from datetime import datetime , timezone
66import io
77import logging
8+ import os
89
9- from PIL import Image , UnidentifiedImageError
10+ from PIL import Image
1011from roborock .devices .traits .v1 .home import HomeTrait
1112from roborock .devices .traits .v1 .map_content import MapContent
1213
@@ -121,12 +122,56 @@ def __init__(
121122 self ._raw_image_size : tuple [int , int ] | None = None
122123
123124 self ._attr_entity_category = EntityCategory .DIAGNOSTIC
125+ self ._reload_time = dt_util .utcnow ()
124126
125127 @property
126128 def is_selected (self ) -> bool :
127129 """Return if this map is the currently selected map."""
128130 return self .map_flag == self .coordinator .properties_api .maps .current_map
129131
132+ @property
133+ def image_last_updated (self ) -> datetime | None :
134+ """Return the time the image was last updated, dynamically busting caches."""
135+ base_dt = self ._reload_time
136+ coord_dt = self .coordinator .last_home_update
137+ if coord_dt is not None :
138+ base_dt = max (base_dt , coord_dt )
139+
140+ # Lightly scan custom image paths to find the current mtime dynamically
141+ try :
142+ for path in self ._get_candidate_paths ():
143+ if os .path .isfile (path ):
144+ mtime = os .path .getmtime (path )
145+ custom_dt = datetime .fromtimestamp (mtime , tz = timezone .utc )
146+ return max (base_dt , custom_dt )
147+ except Exception :
148+ pass
149+
150+ return base_dt
151+
152+ def _get_candidate_paths (self ) -> list [str ]:
153+ """Return the list of candidate paths for the custom map image."""
154+ config_dir = self .hass .config .config_dir
155+ search_dirs = [
156+ os .path .join (config_dir , "www" ),
157+ os .path .join (config_dir , "media" ),
158+ "/media" ,
159+ config_dir ,
160+ ]
161+ basenames = [
162+ f"roborock_custom_map_{ self .map_flag } _hide_rugs" ,
163+ f"roborock_custom_map_{ self .map_flag } " ,
164+ "roborock_custom_map_hide_rugs" ,
165+ "roborock_custom_map" ,
166+ ]
167+ extensions = [".webp" , ".png" ]
168+ return [
169+ os .path .join (sdir , f"{ base } { ext } " )
170+ for sdir in search_dirs
171+ for base in basenames
172+ for ext in extensions
173+ ]
174+
130175 @property
131176 def _map_content (self ) -> MapContent | None :
132177 if self ._home_trait .home_map_content and (
@@ -150,6 +195,11 @@ async def async_added_to_hass(self) -> None:
150195 )
151196 )
152197
198+ # Populate initial map info if already loaded
199+ if (map_content := self ._map_content ) is not None :
200+ self .cached_map = map_content .image_content
201+ self ._raw_image_size = _png_dimensions (self .cached_map )
202+
153203 self .async_write_ha_state ()
154204
155205 def _handle_rotation_changed (self ) -> None :
@@ -169,10 +219,93 @@ def _handle_coordinator_update(self) -> None:
169219
170220 super ()._handle_coordinator_update ()
171221
172- def _rotate_image (self , raw : bytes , rotation : int ) -> bytes :
173- """Rotate image in executor thread."""
222+ def _load_custom_image (self , target_size : tuple [int , int ]) -> tuple [Image .Image , str ] | tuple [None , None ]:
223+ """Find, load, and resize a custom map image from the filesystem on demand."""
224+ paths = self ._get_candidate_paths ()
225+
226+ for path in paths :
227+ if os .path .isfile (path ):
228+ try :
229+ _LOGGER .info ("Loading custom map background from %s" , path )
230+ img = Image .open (path )
231+ img .load ()
232+ img = img .convert ("RGBA" )
233+ if img .size != target_size :
234+ img = img .resize (target_size , Image .Resampling .LANCZOS )
235+ return img , path
236+ except Exception as err :
237+ _LOGGER .error ("Failed to load custom map image from %s: %s" , path , err )
238+
239+ return None , None
240+
241+ def _remove_carpet_pattern (self , img : Image .Image ) -> Image .Image :
242+ """Filter out the grey and colored carpet checkerboard patterns from the foreground map."""
243+ try :
244+ img = img .convert ("RGBA" )
245+ import numpy as np
246+ img_arr = np .array (img )
247+ r = img_arr [:, :, 0 ].astype (int )
248+ g = img_arr [:, :, 1 ].astype (int )
249+ b = img_arr [:, :, 2 ].astype (int )
250+ a = img_arr [:, :, 3 ].astype (int )
251+
252+ # Grey condition: R, G, B components are very close to each other
253+ max_val = np .maximum (np .maximum (r , g ), b )
254+ min_val = np .minimum (np .minimum (r , g ), b )
255+ is_grey = (max_val - min_val < 15 )
256+
257+ # Green carpet condition: (169, 247, 169)
258+ is_green_carpet = (np .abs (r - 169 ) < 15 ) & (np .abs (g - 247 ) < 15 ) & (np .abs (b - 169 ) < 15 )
259+
260+ # Sage/teal carpet condition: (101, 181, 170)
261+ is_sage_carpet = (np .abs (r - 101 ) < 15 ) & (np .abs (g - 181 ) < 15 ) & (np .abs (b - 170 ) < 15 )
262+
263+ # Combine all carpet detections
264+ is_carpet = is_grey | is_green_carpet | is_sage_carpet
265+
266+ # Protect pure white outlines/paths
267+ is_not_white = (r < 240 ) | (g < 240 ) | (b < 240 )
268+
269+ # Protect dark black outlines
270+ is_not_black = (r > 40 ) | (g > 40 ) | (b > 40 )
271+
272+ # Make carpet pattern pixels transparent
273+ carpet_mask = is_carpet & is_not_white & is_not_black & (a > 0 )
274+ img_arr [carpet_mask ] = [0 , 0 , 0 , 0 ]
275+ return Image .fromarray (img_arr )
276+ except Exception as err :
277+ _LOGGER .error ("Error filtering carpet pattern: %s" , err )
278+ return img
279+
280+ def _process_image (self , raw : bytes , rotation : int ) -> bytes :
281+ """Overlay custom map image and rotate, stateless on demand."""
174282 img = Image .open (io .BytesIO (raw ))
175- img = img .rotate (rotation , expand = True )
283+ target_size = img .size
284+
285+ custom_img , custom_path = self ._load_custom_image (target_size )
286+
287+ # Check if we should filter out carpets based on the file name indicator
288+ filter_carpet = False
289+ if custom_path :
290+ filename = os .path .basename (custom_path ).lower ()
291+ name_part , _ = os .path .splitext (filename )
292+ if name_part .endswith ("_hide_rugs" ):
293+ filter_carpet = True
294+
295+ if custom_img is not None :
296+ try :
297+ if filter_carpet :
298+ img = self ._remove_carpet_pattern (img )
299+ else :
300+ img = img .convert ("RGBA" )
301+
302+ # Simple and fast alpha composite overlay!
303+ img = Image .alpha_composite (custom_img , img )
304+ except Exception as err :
305+ _LOGGER .error ("Error overlaying custom map image: %s" , err )
306+
307+ if rotation != 0 :
308+ img = img .rotate (rotation , expand = True )
176309
177310 out = io .BytesIO ()
178311 img .save (out , format = "PNG" )
@@ -199,23 +332,20 @@ def _get_rotation(self) -> int:
199332 return rotation
200333
201334 async def async_image (self ) -> bytes | None :
202- """Get the image (with optional rotation)."""
335+ """Get the image (with optional rotation and custom overlay )."""
203336 if (map_content := self ._map_content ) is None :
204337 raise HomeAssistantError ("Map flag not found in coordinator maps" )
205338
206339 raw = map_content .image_content
207340 rotation = self ._get_rotation ()
208341
209- if rotation == DEFAULT_MAP_ROTATION :
210- return raw
211-
212342 try :
213343 return await self .hass .async_add_executor_job (
214- self ._rotate_image , raw , rotation
344+ self ._process_image , raw , rotation
215345 )
216- except ( OSError , UnidentifiedImageError ) as err :
346+ except Exception as err :
217347 _LOGGER .debug (
218- "Failed to rotate Roborock map image: %s, returning original image" ,
348+ "Failed to process Roborock map image: %s, returning original image" ,
219349 err ,
220350 )
221351 return raw
0 commit comments