Skip to content

Commit 262afb5

Browse files
committed
refactor(map): standardize protocol commands and improve state consistency
- Standardize RoborockCommand enum with missing map edit constants - Implement additive edit batching in TranslationLayer to prevent state overwrite - Enhance VirtualState with comprehensive state capture (walls, zones, mop zones) - Add refresh_base_map to VirtualState for post-sync synchronization - Fix MapVerifier to accurately validate virtual wall endpoints and directions - Add map version safety check to CLI to detect state drift - Improve map_edit_status CLI output with granular edit details Entire-Checkpoint: f7bfcf7b1097
1 parent cda33e5 commit 262afb5

5 files changed

Lines changed: 208 additions & 23 deletions

File tree

roborock/cli.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -182,9 +182,24 @@ def __init__(self):
182182
self._virtual_states: dict[str, Any] = {}
183183

184184
def get_virtual_state(self, device_id: str, map_data: Any = None) -> Any:
185-
"""Get or create a VirtualState for a device."""
185+
"""Get or create a VirtualState for a device with version safety."""
186186
from roborock.map import CoordinateTransformer, VirtualState
187187

188+
if device_id in self._virtual_states:
189+
state = self._virtual_states[device_id]
190+
# If map_data is provided, check for state drift
191+
if map_data is not None and state._base_map is not None:
192+
# Simple version check: room count or timestamp
193+
curr_rooms = set(map_data.rooms.keys()) if map_data.rooms else set()
194+
base_rooms = set(state._base_map.rooms.keys()) if state._base_map.rooms else set()
195+
196+
if curr_rooms != base_rooms:
197+
click.echo(f"WARNING: Device map for {device_id} has changed. Local edits may be invalid.")
198+
if click.confirm("Clear pending edits and refresh state?"):
199+
del self._virtual_states[device_id]
200+
else:
201+
click.echo("Continuing with potentially stale state (DANGEROUS)")
202+
188203
if device_id not in self._virtual_states:
189204
if map_data is None:
190205
return None
@@ -1373,17 +1388,18 @@ def _generate_preview(map_data, virtual_state, transformer, output_path: str = "
13731388
draw = ImageDraw.Draw(img)
13741389

13751390
# Draw each pending edit in red
1391+
from roborock.map.editor import EditType
13761392
for edit in virtual_state.pending_edits:
1377-
if edit.edit_type.value == "split_room":
1393+
if edit.edit_type == EditType.SPLIT_ROOM:
13781394
# Draw split line in red
13791395
p1 = transformer.robot_to_image(Point(edit.x1, edit.y1))
13801396
p2 = transformer.robot_to_image(Point(edit.x2, edit.y2))
13811397
draw.line([(int(p1.x), int(p1.y)), (int(p2.x), int(p2.y))], fill=(255, 0, 0), width=3)
1382-
elif edit.edit_type.value in ["virtual_wall", "no_go_zone"]:
1398+
elif edit.edit_type in [EditType.VIRTUAL_WALL, EditType.NO_GO_ZONE]:
13831399
# Draw virtual walls/no-go zones in red
13841400
p1 = transformer.robot_to_image(Point(edit.x1, edit.y1))
13851401
p2 = transformer.robot_to_image(Point(edit.x2, edit.y2))
1386-
if edit.edit_type.value == "virtual_wall":
1402+
if edit.edit_type == EditType.VIRTUAL_WALL:
13871403
draw.line([(int(p1.x), int(p1.y)), (int(p2.x), int(p2.y))], fill=(255, 0, 0), width=3)
13881404
else:
13891405
# No-go zone - draw rectangle
@@ -1865,6 +1881,14 @@ async def add_no_go_zone(ctx, device_id: str, x1: int, y1: int, x2: int, y2: int
18651881
@async_command
18661882
async def map_edit_status(ctx, device_id: str):
18671883
"""Show pending edits in the virtual state."""
1884+
from roborock.map.editor import (
1885+
MergeRoomsEdit,
1886+
NoGoZoneEdit,
1887+
RenameRoomEdit,
1888+
SplitRoomEdit,
1889+
VirtualWallEdit,
1890+
)
1891+
18681892
context: RoborockContext = ctx.obj
18691893
virtual_state = context.get_virtual_state(device_id)
18701894

@@ -1874,7 +1898,19 @@ async def map_edit_status(ctx, device_id: str):
18741898

18751899
click.echo(f"Pending edits for device {device_id}:")
18761900
for i, edit in enumerate(virtual_state.pending_edits):
1877-
click.echo(f" {i+1}. {edit.edit_type.name} (Status: {edit.status.name})")
1901+
details = ""
1902+
if isinstance(edit, VirtualWallEdit):
1903+
details = f"({edit.x1:.0f}, {edit.y1:.0f}) -> ({edit.x2:.0f}, {edit.y2:.0f})"
1904+
elif isinstance(edit, NoGoZoneEdit):
1905+
details = f"[{edit.x1:.0f}, {edit.y1:.0f}, {edit.x2:.0f}, {edit.y2:.0f}]"
1906+
elif isinstance(edit, SplitRoomEdit):
1907+
details = f"Room {edit.segment_id} @ ({edit.x1:.0f}, {edit.y1:.0f}) -> ({edit.x2:.0f}, {edit.y2:.0f})"
1908+
elif isinstance(edit, MergeRoomsEdit):
1909+
details = f"Rooms: {edit.segment_ids}"
1910+
elif isinstance(edit, RenameRoomEdit):
1911+
details = f"Room {edit.segment_id}: '{edit.old_name}' -> '{edit.new_name}'"
1912+
1913+
click.echo(f" {i+1}. {edit.edit_type.name:15} {details} (Status: {edit.status.name})")
18781914

18791915

18801916
@session.command()

roborock/map/editor.py

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -395,14 +395,39 @@ def __init__(self, map_data: MapData | None = None, transformer: CoordinateTrans
395395
self._transformer = transformer
396396
self._edit_stack: list[EditObject] = []
397397
self._redo_stack: list[EditObject] = []
398+
399+
# Capture original state for full physical rollback support
398400
self._original_room_names: dict[int, str] = {}
399-
400-
# Capture original room names for rollback
401-
if map_data and map_data.rooms:
402-
for room_id, room in map_data.rooms.items():
403-
if hasattr(room, 'name'):
404-
self._original_room_names[room_id] = room.name
405-
401+
self._original_walls: list[tuple[float, float, float, float]] = []
402+
self._original_no_go_zones: list[tuple[float, float, float, float]] = []
403+
self._original_mop_zones: list[tuple[float, float, float, float]] = []
404+
405+
if map_data:
406+
# Capture rooms
407+
if map_data.rooms:
408+
for room_id, room in map_data.rooms.items():
409+
if hasattr(room, 'name') and room.name:
410+
self._original_room_names[room_id] = room.name
411+
412+
# Capture walls (if exposed by parser)
413+
if hasattr(map_data, 'walls') and map_data.walls:
414+
for wall in map_data.walls:
415+
self._original_walls.append((wall.x1, wall.y1, wall.x2, wall.y2))
416+
417+
# Capture zones (if exposed by parser)
418+
if hasattr(map_data, 'no_go_areas') and map_data.no_go_areas:
419+
for zone in map_data.no_go_areas:
420+
# Note: we simplify to 4-coord bbox for compatibility
421+
xs = [p.x for p in zone.points]
422+
ys = [p.y for p in zone.points]
423+
self._original_no_go_zones.append((min(xs), min(ys), max(xs), max(ys)))
424+
425+
# Capture mop zones
426+
if hasattr(map_data, 'no_mop_areas') and map_data.no_mop_areas:
427+
for zone in map_data.no_mop_areas:
428+
xs = [p.x for p in zone.points]
429+
ys = [p.y for p in zone.points]
430+
self._original_mop_zones.append((min(xs), min(ys), max(xs), max(ys)))
406431
@property
407432
def has_pending_edits(self) -> bool:
408433
"""Return True if there are pending edits."""
@@ -492,6 +517,21 @@ def redo(self) -> EditObject | None:
492517
_LOGGER.debug(f"Redone edit: {edit.edit_id}")
493518
return edit
494519

520+
def refresh_base_map(self, map_data: MapData) -> None:
521+
"""Refresh the base map and coordinate transformer.
522+
523+
This is used after a physical sync to ensure the virtual state
524+
matches the current device state.
525+
526+
Args:
527+
map_data: The fresh map data from device.
528+
"""
529+
from .geometry import CoordinateTransformer
530+
531+
self._base_map = map_data
532+
self._transformer = CoordinateTransformer.from_map_data(map_data)
533+
_LOGGER.debug("Refreshed base map in VirtualState")
534+
495535
def clear(self) -> None:
496536
"""Clear all edits and reset to base state."""
497537
self._edit_stack.clear()
@@ -541,6 +581,8 @@ def to_dict(self) -> dict[str, Any]:
541581
return {
542582
"edits": [e.to_dict() for e in self._edit_stack],
543583
"original_room_names": self._original_room_names,
584+
"original_walls": self._original_walls,
585+
"original_no_go_zones": self._original_no_go_zones,
544586
}
545587

546588
def __len__(self) -> int:

roborock/map/translation.py

Lines changed: 105 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -95,12 +95,12 @@ def translate_virtual_wall(self, edit: VirtualWallEdit) -> tuple[str, Any]:
9595
"""
9696
# V1 uses the full map update approach
9797
wall_data = [int(edit.x1), int(edit.y1), int(edit.x2), int(edit.y2)]
98-
return "set_virtual_wall", [wall_data]
98+
return RoborockCommand.SET_VIRTUAL_WALL, [wall_data]
9999

100100
def translate_no_go_zone(self, edit: NoGoZoneEdit) -> tuple[str, Any]:
101101
"""Translate to SET_NO_GO_ZONES command."""
102102
zone_data = [int(edit.x1), int(edit.y1), int(edit.x2), int(edit.y2)]
103-
return "set_no_go_zones", [zone_data]
103+
return RoborockCommand.SET_NO_GO_ZONES, [zone_data]
104104

105105
def translate_split_room(self, edit: SplitRoomEdit) -> tuple[str, Any]:
106106
"""Translate to SPLIT_SEGMENT command.
@@ -264,12 +264,110 @@ async def execute_edits(
264264
additive_edits = virtual_state.get_additive_edits()
265265
if additive_edits and topology_success:
266266
_LOGGER.info(f"Stage 3: Executing {len(additive_edits)} additive edits")
267-
for edit in additive_edits:
268-
result = await self._execute_edit(edit, map_flag)
269-
results.append(result)
267+
268+
if self._protocol == "v1":
269+
# V1 requires batching additive edits as they overwrite the entire state
270+
batch_results = await self._execute_v1_additive_batch(virtual_state, additive_edits, map_flag)
271+
results.extend(batch_results)
272+
else:
273+
# B01 might support individual updates (verify)
274+
for edit in additive_edits:
275+
result = await self._execute_edit(edit, map_flag)
276+
results.append(result)
270277

271278
return results
272279

280+
async def _execute_v1_additive_batch(
281+
self,
282+
virtual_state: VirtualState,
283+
edits: list[EditObject],
284+
map_flag: int | None,
285+
) -> list[TranslationResult]:
286+
"""Execute additive edits as a batch for V1 protocol.
287+
288+
V1 commands like set_virtual_wall overwrite the entire list,
289+
so we must combine new edits with existing state.
290+
"""
291+
results: list[TranslationResult] = []
292+
pending_walls = [e for e in edits if isinstance(e, VirtualWallEdit) and e.status == EditStatus.APPLIED]
293+
pending_zones = [e for e in edits if isinstance(e, NoGoZoneEdit) and e.status == EditStatus.APPLIED]
294+
pending_mop_zones = [e for e in edits if e.edit_type == EditType.MOP_FORBIDDEN_ZONE and e.status == EditStatus.APPLIED]
295+
296+
# 1. Handle Virtual Walls
297+
if pending_walls:
298+
# Combine original walls with new ones
299+
all_walls = list(virtual_state._original_walls)
300+
for wall in pending_walls:
301+
all_walls.append((wall.x1, wall.y1, wall.x2, wall.y2))
302+
303+
# Format: [map_flag, [[x1, y1, x2, y2], ...]]
304+
params = [list(w) for w in all_walls]
305+
if map_flag is not None:
306+
params = [map_flag, params]
307+
308+
try:
309+
_LOGGER.debug(f"Sending V1 wall batch: {params}")
310+
await self._command.send(RoborockCommand.SET_VIRTUAL_WALL, params)
311+
for edit in pending_walls:
312+
edit.status = EditStatus.SYNCED
313+
results.append(TranslationResult(success=True, edit=edit))
314+
except Exception as e:
315+
_LOGGER.error(f"Failed to sync wall batch: {e}")
316+
for edit in pending_walls:
317+
edit.status = EditStatus.FAILED
318+
results.append(TranslationResult(success=False, edit=edit, error=str(e)))
319+
320+
# 2. Handle No-Go Zones
321+
if pending_zones:
322+
# Combine original zones with new ones
323+
all_zones = list(virtual_state._original_no_go_zones)
324+
for zone in pending_zones:
325+
all_zones.append((zone.x1, zone.y1, zone.x2, zone.y2))
326+
327+
# Format: [map_flag, [[x1, y1, x2, y2], ...]]
328+
params = [list(z) for z in all_zones]
329+
if map_flag is not None:
330+
params = [map_flag, params]
331+
332+
try:
333+
_LOGGER.debug(f"Sending V1 zone batch: {params}")
334+
await self._command.send(RoborockCommand.SET_NO_GO_ZONES, params)
335+
for edit in pending_zones:
336+
edit.status = EditStatus.SYNCED
337+
results.append(TranslationResult(success=True, edit=edit))
338+
except Exception as e:
339+
_LOGGER.error(f"Failed to sync zone batch: {e}")
340+
for edit in pending_zones:
341+
edit.status = EditStatus.FAILED
342+
results.append(TranslationResult(success=False, edit=edit, error=str(e)))
343+
344+
# 3. Handle Mop Forbidden Zones
345+
if pending_mop_zones:
346+
# Combine original mop zones with new ones
347+
all_mop_zones = list(virtual_state._original_mop_zones)
348+
for zone in pending_mop_zones:
349+
# Mop zones are typically same 4-coord format
350+
if hasattr(zone, 'x1'):
351+
all_mop_zones.append((zone.x1, zone.y1, zone.x2, zone.y2))
352+
353+
params = [list(z) for z in all_mop_zones]
354+
if map_flag is not None:
355+
params = [map_flag, params]
356+
357+
try:
358+
_LOGGER.debug(f"Sending V1 mop zone batch: {params}")
359+
await self._command.send(RoborockCommand.SET_MOP_FORBIDDEN_ZONE, params)
360+
for edit in pending_mop_zones:
361+
edit.status = EditStatus.SYNCED
362+
results.append(TranslationResult(success=True, edit=edit))
363+
except Exception as e:
364+
_LOGGER.error(f"Failed to sync mop zone batch: {e}")
365+
for edit in pending_mop_zones:
366+
edit.status = EditStatus.FAILED
367+
results.append(TranslationResult(success=False, edit=edit, error=str(e)))
368+
369+
return results
370+
273371
async def _execute_edit(
274372
self,
275373
edit: EditObject,
@@ -500,5 +598,5 @@ async def _repopulate_room_ids(self, virtual_state: VirtualState) -> None:
500598

501599
# 5. Update the base map for the VirtualState so subsequent matches are correct
502600
# This is important if we're doing multiple rounds or just to keep state consistent.
503-
# virtual_state._base_map = new_map (Internal access needed or a public setter)
504-
# For now, we've updated the pending edits which is the primary goal for execution.
601+
virtual_state.refresh_base_map(new_map)
602+
_LOGGER.info("Refreshed VirtualState base map after remapping")

roborock/map/verifier.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -200,14 +200,20 @@ def _verify_virtual_wall(
200200
# Check for matching wall (with tolerance)
201201
tolerance = 100 # 10cm in mm
202202
for wall in map_data.walls:
203-
wall_point1 = Point(wall.x, wall.y)
204-
# Wall may be stored as single point with orientation, or two points
205-
if self._points_match(Point(edit.x1, edit.y1), wall_point1, tolerance):
203+
# Wall has x1, y1, x2, y2 from parser
204+
w_p1 = Point(wall.x1, wall.y1)
205+
w_p2 = Point(wall.x2, wall.y2)
206+
e_p1 = Point(edit.x1, edit.y1)
207+
e_p2 = Point(edit.x2, edit.y2)
208+
209+
# Check both directions
210+
if (self._points_match(e_p1, w_p1, tolerance) and self._points_match(e_p2, w_p2, tolerance)) or \
211+
(self._points_match(e_p1, w_p2, tolerance) and self._points_match(e_p2, w_p1, tolerance)):
206212
return VerificationResult(
207213
verified=True,
208214
edit_type="VIRTUAL_WALL",
209215
expected=expected,
210-
actual={"x": wall.x, "y": wall.y},
216+
actual={"x1": wall.x1, "y1": wall.y1, "x2": wall.x2, "y2": wall.y2},
211217
)
212218

213219
return VerificationResult(

roborock/roborock_typing.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,9 +208,11 @@ class RoborockCommand(str, Enum):
208208
SET_LAB_STATUS = "set_lab_status"
209209
SET_LED_STATUS = "set_led_status"
210210
SET_MAP_BEAUTIFICATION_STATUS = "set_map_beautification_status"
211+
SET_MOP_FORBIDDEN_ZONE = "set_mop_forbidden_zone"
211212
SET_MOP_MODE = "set_mop_mode"
212213
SET_MOP_MOTOR_STATUS = "set_mop_motor_status"
213214
SET_MOP_TEMPLATE_ID = "set_mop_template_id"
215+
SET_NO_GO_ZONES = "set_no_go_zones"
214216
SET_OFFLINE_MAP_STATUS = "set_offline_map_status"
215217
SET_SCENES_SEGMENTS = "set_scenes_segments"
216218
SET_SCENES_ZONES = "set_scenes_zones"
@@ -221,6 +223,7 @@ class RoborockCommand(str, Enum):
221223
SET_TIMER = "set_timer"
222224
SET_TIMEZONE = "set_timezone"
223225
SET_VALLEY_ELECTRICITY_TIMER = "set_valley_electricity_timer"
226+
SET_VIRTUAL_WALL = "set_virtual_wall"
224227
SET_VOICE_CHAT_VOLUME = "set_voice_chat_volume"
225228
SET_WASH_DEBUG_PARAMS = "set_wash_debug_params"
226229
SET_WASH_TOWEL_MODE = "set_wash_towel_mode"

0 commit comments

Comments
 (0)