@@ -180,9 +180,18 @@ def __init__(self):
180180 self ._session_thread : threading .Thread | None = None
181181 self ._device_manager : DeviceConnectionManager | None = None
182182 self ._virtual_states : dict [str , Any ] = {}
183+ self ._virtual_states_dir : Path = Path ("~/.config/roborock/virtual_states/" ).expanduser ()
183184
184- def get_virtual_state (self , device_id : str , map_data : Any = None ) -> Any :
185- """Get or create a VirtualState for a device with version safety."""
185+ def _get_virtual_state_path (self , device_id : str ) -> Path :
186+ """Get the path for a device's virtual state file."""
187+ return self ._virtual_states_dir / f"{ device_id } .json"
188+
189+ async def get_virtual_state (self , device_id : str , map_data : Any = None ) -> Any :
190+ """Get or create a VirtualState for a device with version safety.
191+
192+ If a persisted state exists and map_data is provided, attempts to load it.
193+ Handles stale states by clearing them and returning a fresh state.
194+ """
186195 from roborock .map import CoordinateTransformer , VirtualState
187196
188197 if device_id in self ._virtual_states :
@@ -205,18 +214,121 @@ def get_virtual_state(self, device_id: str, map_data: Any = None) -> Any:
205214 return None
206215
207216 transformer = CoordinateTransformer .from_map_data (map_data )
208- state = VirtualState (map_data , transformer )
209- state_file = Path (f"~/.roborock.map_edit_{ device_id } .json" ).expanduser ()
210- state .load (state_file )
217+ state_file = self ._get_virtual_state_path (device_id )
218+
219+ # Try to load existing state
220+ if state_file .exists ():
221+ try :
222+ state = await VirtualState .load (state_file , map_data )
223+ _LOGGER .info (f"Loaded persisted virtual state for { device_id } with { len (state )} edits" )
224+ except (ValueError , FileNotFoundError ) as e :
225+ _LOGGER .warning (f"Could not load persisted state for { device_id } : { e } " )
226+ # Create fresh state
227+ state = VirtualState (map_data , transformer )
228+ else :
229+ state = VirtualState (map_data , transformer )
230+
211231 self ._virtual_states [device_id ] = state
212232
213233 return self ._virtual_states [device_id ]
214234
215- def save_virtual_state (self , device_id : str ) -> None :
216- """Save the virtual state for a device to disk."""
217- if device_id in self ._virtual_states :
218- state_file = Path (f"~/.roborock.map_edit_{ device_id } .json" ).expanduser ()
219- self ._virtual_states [device_id ].save (state_file )
235+ async def save_virtual_state (self , device_id : str ) -> None :
236+ """Save the virtual state for a device to disk.
237+
238+ Only saves if there are pending edits.
239+ """
240+ if device_id not in self ._virtual_states :
241+ return
242+
243+ state = self ._virtual_states [device_id ]
244+ if not state .has_pending_edits :
245+ return
246+
247+ state_file = self ._get_virtual_state_path (device_id )
248+ await state .save (state_file )
249+ _LOGGER .debug (f"Saved virtual state for { device_id } " )
250+
251+ async def save_virtual_states (self ) -> None :
252+ """Save all virtual states to disk.
253+
254+ Called on exit to persist pending edits.
255+ """
256+ self ._virtual_states_dir .mkdir (parents = True , exist_ok = True )
257+
258+ for device_id , state in self ._virtual_states .items ():
259+ if state .has_pending_edits :
260+ try :
261+ state_file = self ._get_virtual_state_path (device_id )
262+ await state .save (state_file )
263+ _LOGGER .info (f"Saved virtual state for { device_id } with { len (state )} edits" )
264+ except Exception as e :
265+ _LOGGER .error (f"Failed to save virtual state for { device_id } : { e } " )
266+
267+ def load_virtual_states (self , available_devices : list [str ] | None = None ) -> dict [str , dict ]:
268+ """Load and validate virtual states from disk on startup.
269+
270+ Args:
271+ available_devices: Optional list of currently available device IDs.
272+ States for unavailable devices are reported as stale.
273+
274+ Returns:
275+ Dictionary mapping device_id to load status info:
276+ - "loaded": bool - Whether state was successfully loaded
277+ - "edits": int - Number of edits in loaded state (0 if not loaded)
278+ - "stale": bool - True if device not in available_devices
279+ - "error": str - Error message if loading failed
280+ """
281+ import json
282+
283+ results : dict [str , dict ] = {}
284+
285+ if not self ._virtual_states_dir .exists ():
286+ return results
287+
288+ for state_file in self ._virtual_states_dir .glob ("*.json" ):
289+ device_id = state_file .stem
290+
291+ # Check if device is available
292+ if available_devices is not None and device_id not in available_devices :
293+ results [device_id ] = {
294+ "loaded" : False ,
295+ "edits" : 0 ,
296+ "stale" : True ,
297+ "error" : "Device not currently available"
298+ }
299+ continue
300+
301+ # Just check file is valid JSON and has expected structure
302+ try :
303+ with open (state_file , "r" , encoding = "utf-8" ) as f :
304+ data = json .load (f )
305+
306+ edit_count = len (data .get ("edits" , []))
307+ map_hash = data .get ("map_hash" )
308+ map_flag = data .get ("map_flag" )
309+
310+ results [device_id ] = {
311+ "loaded" : True ,
312+ "edits" : edit_count ,
313+ "stale" : False ,
314+ "map_hash" : map_hash ,
315+ "map_flag" : map_flag ,
316+ "timestamp" : data .get ("timestamp" ),
317+ "error" : None
318+ }
319+
320+ _LOGGER .info (f"Found persisted state for { device_id } : { edit_count } edits" )
321+
322+ except Exception as e :
323+ results [device_id ] = {
324+ "loaded" : False ,
325+ "edits" : 0 ,
326+ "stale" : False ,
327+ "error" : str (e )
328+ }
329+ _LOGGER .error (f"Failed to validate state file for { device_id } : { e } " )
330+
331+ return results
220332
221333 def reload (self ):
222334 if self .roborock_file .is_file ():
@@ -293,6 +405,9 @@ async def get_devices(self) -> ConnectionCache:
293405
294406 async def cleanup (self ):
295407 """Clean up resources (mainly for session mode)."""
408+ # Save virtual states before cleanup
409+ await self .save_virtual_states ()
410+
296411 if self ._device_manager :
297412 await self ._device_manager .close ()
298413 self ._device_manager = None
@@ -1602,11 +1717,11 @@ async def split_room(ctx, device_id: str, room: str, direction: str, ratio: floa
16021717 split_line = calculate_split_line (room_bbox , direction , ratio )
16031718
16041719 # Create virtual state and add edit
1605- virtual_state = context .get_virtual_state (device_id , map_data )
1720+ virtual_state = await context .get_virtual_state (device_id , map_data )
16061721 if virtual_state is None :
16071722 click .echo ("Failed to initialize virtual state" )
16081723 return
1609-
1724+
16101725 edit = SplitRoomEdit (
16111726 segment_id = target_room_id ,
16121727 x1 = split_line .p1 .x ,
@@ -1615,7 +1730,7 @@ async def split_room(ctx, device_id: str, room: str, direction: str, ratio: floa
16151730 y2 = split_line .p2 .y ,
16161731 )
16171732
1618- success , error = virtual_state .add_edit (edit )
1733+ success , error = await virtual_state .add_edit (edit )
16191734 if not success :
16201735 click .echo (f"Failed to create edit: { error } " )
16211736 return
@@ -1685,14 +1800,14 @@ async def merge_rooms(ctx, device_id: str, rooms: str, apply: bool, preview: boo
16851800 return
16861801
16871802 transformer = CoordinateTransformer .from_map_data (map_data )
1688- virtual_state = context .get_virtual_state (device_id , map_data )
1803+ virtual_state = await context .get_virtual_state (device_id , map_data )
16891804 if virtual_state is None :
16901805 click .echo ("Failed to initialize virtual state" )
16911806 return
1692-
1807+
16931808 edit = MergeRoomsEdit (segment_ids = segment_ids )
16941809
1695- success , error = virtual_state .add_edit (edit )
1810+ success , error = await virtual_state .add_edit (edit )
16961811 if not success :
16971812 click .echo (f"Failed to create edit: { error } " )
16981813 return
@@ -1762,18 +1877,18 @@ async def rename_room(ctx, device_id: str, room: str, new_name: str, apply: bool
17621877 return
17631878
17641879 transformer = CoordinateTransformer .from_map_data (map_data )
1765- virtual_state = context .get_virtual_state (device_id , map_data )
1880+ virtual_state = await context .get_virtual_state (device_id , map_data )
17661881 if virtual_state is None :
17671882 click .echo ("Failed to initialize virtual state" )
17681883 return
1769-
1884+
17701885 edit = RenameRoomEdit (
17711886 segment_id = target_room_id ,
17721887 new_name = new_name ,
17731888 old_name = old_name or "" ,
17741889 )
17751890
1776- success , error = virtual_state .add_edit (edit )
1891+ success , error = await virtual_state .add_edit (edit )
17771892 if not success :
17781893 click .echo (f"Failed to create edit: { error } " )
17791894 return
@@ -1829,14 +1944,14 @@ async def add_virtual_wall(ctx, device_id: str, x1: int, y1: int, x2: int, y2: i
18291944
18301945 map_data = map_trait .map_data
18311946 transformer = CoordinateTransformer .from_map_data (map_data )
1832- virtual_state = context .get_virtual_state (device_id , map_data )
1947+ virtual_state = await context .get_virtual_state (device_id , map_data )
18331948 if virtual_state is None :
18341949 click .echo ("Failed to initialize virtual state" )
18351950 return
18361951
18371952 edit = VirtualWallEdit (x1 = float (x1 ), y1 = float (y1 ), x2 = float (x2 ), y2 = float (y2 ))
18381953
1839- success , error = virtual_state .add_edit (edit )
1954+ success , error = await virtual_state .add_edit (edit )
18401955 if not success :
18411956 click .echo (f"Failed to create edit: { error } " )
18421957 return
@@ -1892,14 +2007,14 @@ async def add_no_go_zone(ctx, device_id: str, x1: int, y1: int, x2: int, y2: int
18922007
18932008 map_data = map_trait .map_data
18942009 transformer = CoordinateTransformer .from_map_data (map_data )
1895- virtual_state = context .get_virtual_state (device_id , map_data )
2010+ virtual_state = await context .get_virtual_state (device_id , map_data )
18962011 if virtual_state is None :
18972012 click .echo ("Failed to initialize virtual state" )
18982013 return
18992014
19002015 edit = NoGoZoneEdit (x1 = float (x1 ), y1 = float (y1 ), x2 = float (x2 ), y2 = float (y2 ))
19012016
1902- success , error = virtual_state .add_edit (edit )
2017+ success , error = await virtual_state .add_edit (edit )
19032018 if not success :
19042019 click .echo (f"Failed to create edit: { error } " )
19052020 return
@@ -1935,7 +2050,7 @@ async def map_edit_status(ctx, device_id: str):
19352050 )
19362051
19372052 context : RoborockContext = ctx .obj
1938- virtual_state = context .get_virtual_state (device_id )
2053+ virtual_state = await context .get_virtual_state (device_id )
19392054
19402055 if not virtual_state or not virtual_state .has_pending_edits :
19412056 click .echo ("No pending edits" )
@@ -1980,10 +2095,10 @@ async def map_edit_sync(ctx, device_id: str):
19802095 success = await _execute_edit (device , virtual_state , map_flag )
19812096 if success :
19822097 click .echo ("Sync successful. Clearing pending edits." )
1983- virtual_state .clear ()
1984- context .save_virtual_state (device_id )
2098+ await virtual_state .clear ()
2099+ await context .save_virtual_state (device_id )
19852100 else :
1986- click .echo (\ "Sync failed or partially completed .\ " )
2101+ click .echo ("Sync failed or partially completed." )
19872102
19882103
19892104@session .command ()
@@ -1993,13 +2108,13 @@ async def map_edit_sync(ctx, device_id: str):
19932108async def map_edit_undo (ctx , device_id : str ):
19942109 """Undo the last pending edit."""
19952110 context : RoborockContext = ctx .obj
1996- virtual_state = context .get_virtual_state (device_id )
2111+ virtual_state = await context .get_virtual_state (device_id )
19972112
19982113 if not virtual_state or not virtual_state .can_undo :
19992114 click .echo ("Nothing to undo" )
20002115 return
20012116
2002- edit = virtual_state .undo ()
2117+ edit = await virtual_state .undo ()
20032118 click .echo (f"Undone: { edit .edit_type .name } " )
20042119
20052120
@@ -2010,13 +2125,13 @@ async def map_edit_undo(ctx, device_id: str):
20102125async def map_edit_redo (ctx , device_id : str ):
20112126 """Redo the last undone edit."""
20122127 context : RoborockContext = ctx .obj
2013- virtual_state = context .get_virtual_state (device_id )
2128+ virtual_state = await context .get_virtual_state (device_id )
20142129
20152130 if not virtual_state or not virtual_state .can_redo :
20162131 click .echo ("Nothing to redo" )
20172132 return
20182133
2019- edit = virtual_state .redo ()
2134+ edit = await virtual_state .redo ()
20202135 click .echo (f"Redone: { edit .edit_type .name } " )
20212136
20222137
@@ -2027,12 +2142,12 @@ async def map_edit_redo(ctx, device_id: str):
20272142async def map_edit_clear (ctx , device_id : str ):
20282143 """Clear all pending edits."""
20292144 context : RoborockContext = ctx .obj
2030- virtual_state = context .get_virtual_state (device_id )
2145+ virtual_state = await context .get_virtual_state (device_id )
20312146
20322147 if virtual_state :
2033- virtual_state .clear ()
2034- context .save_virtual_state (device_id )
2035- click .echo ("Cleared all pending edits" )
2148+ await virtual_state .clear ()
2149+ await context .save_virtual_state (device_id )
2150+ click .echo ("Cleared all pending edits" )
20362151 else :
20372152 click .echo ("No virtual state found for this device" )
20382153
0 commit comments