Skip to content

Commit 27f55f5

Browse files
committed
feat: implement VirtualState persistence (JSON save/load)
Add save/load methods to VirtualState for JSON persistence: - save(path): Serialize edit stack, map identifier, and original state - load(cls, path, map_data): Deserialize with map validation - from_dict/to_dict: Dictionary serialization helpers - _calculate_map_hash(): Map fingerprinting for stale detection - map_hash/map_flag properties for validation Add persistence methods to RoborockContext in cli.py: - save_virtual_states(): Save all states on exit - load_virtual_states(): Load and validate states on startup - _get_virtual_state_path(): Path generation - Updated get_virtual_state() to use new persistence - Added async support throughout Add comprehensive tests in tests/map/test_persistence.py: - test_virtual_state_save_load: Round-trip serialization - test_save_creates_directories: Path handling - test_load_detects_stale_state: Hash validation - test_load_detects_map_flag_change: Flag validation - test_load_missing_file: Error handling - test_save_empty_state: Edge cases - test_original_state_preserved: Rollback data - Map hash calculation tests Entire-Checkpoint: 8ca729c368b2
1 parent 0fd572b commit 27f55f5

7 files changed

Lines changed: 840 additions & 179 deletions

File tree

roborock/cli.py

Lines changed: 150 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -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):
19932108
async 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):
20102125
async 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):
20272142
async 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

Comments
 (0)