Skip to content

Commit df23d69

Browse files
committed
feat(map): implement Local Map Editor (Optimistic UI)
- Add geometric math engine (roborock/map/geometry.py) for coordinate conversion between image space (pixels), grid space, and robot space (mm) - Add virtual state machine (roborock/map/editor.py) with: - EditObject base classes for virtual walls, no-go zones, room split/merge/rename - VirtualState with undo/redo functionality - Structural vs additive edit classification - Inverse command generation for rollback support - Add translation layer (roborock/map/translation.py) with: - Protocol-specific translators (V1 and B01 Q7) - Two-Stage Sync pattern implementation - Map backup/restore for rollback - Add verification loop (roborock/map/verifier.py) with: - Polling-based verification of applied edits - Per-edit-type verification logic - Map version checking for state drift detection - Add CLI commands for map editing: - split-room: Split a room vertically or horizontally - merge-rooms: Merge multiple rooms - rename-room: Rename a room - add-virtual-wall: Add a virtual wall - add-no-go-zone: Add a no-go zone - Add comprehensive tests for geometry and editor modules - Update map module exports Entire-Checkpoint: 8454537f7160
1 parent 1344b96 commit df23d69

8 files changed

Lines changed: 2691 additions & 1 deletion

File tree

roborock/cli.py

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1337,6 +1337,311 @@ async def q10_set_fan_level(ctx: click.Context, device_id: str, level: str) -> N
13371337
click.echo(f"Error: {e}")
13381338

13391339

1340+
# =============================================================================
1341+
# Map Editor Commands
1342+
# =============================================================================
1343+
1344+
1345+
@session.command()
1346+
@click.option("--device_id", required=True, help="Device ID")
1347+
@click.option("--room", required=True, help="Room name to split")
1348+
@click.option("--direction", type=click.Choice(["vertical", "horizontal"]), default="vertical")
1349+
@click.option("--ratio", type=float, default=0.5, help="Split position (0.0-1.0)")
1350+
@click.pass_context
1351+
@async_command
1352+
async def split_room(ctx, device_id: str, room: str, direction: str, ratio: float):
1353+
"""Split a room into two segments."""
1354+
from roborock.map import (
1355+
CoordinateTransformer,
1356+
SplitRoomEdit,
1357+
TranslationLayer,
1358+
VirtualState,
1359+
calculate_split_line,
1360+
)
1361+
from roborock.map.geometry import BoundingBox
1362+
1363+
context: RoborockContext = ctx.obj
1364+
device_manager = await context.get_device_manager()
1365+
device = await device_manager.get_device(device_id)
1366+
1367+
if device.v1_properties is None:
1368+
click.echo("Device does not support V1 protocol")
1369+
return
1370+
1371+
# Get current map
1372+
map_trait = device.v1_properties.map_content
1373+
await map_trait.refresh()
1374+
1375+
if not map_trait.map_data:
1376+
click.echo("No map data available")
1377+
return
1378+
1379+
map_data = map_trait.map_data
1380+
1381+
# Find room by name
1382+
target_room = None
1383+
for room_id, r in (map_data.rooms or {}).items():
1384+
room_name = getattr(r, "name", f"Room {room_id}")
1385+
if room_name.lower() == room.lower():
1386+
target_room = r
1387+
target_room_id = room_id
1388+
break
1389+
1390+
if target_room is None:
1391+
click.echo(f"Room '{room}' not found. Available rooms:")
1392+
for room_id, r in (map_data.rooms or {}).items():
1393+
room_name = getattr(r, "name", f"Room {room_id}")
1394+
click.echo(f" - {room_name} (ID: {room_id})")
1395+
return
1396+
1397+
# Create coordinate transformer
1398+
transformer = CoordinateTransformer.from_map_data(map_data)
1399+
if transformer is None:
1400+
click.echo("Failed to create coordinate transformer")
1401+
return
1402+
1403+
# Calculate split line
1404+
room_bbox = BoundingBox(
1405+
min_x=target_room.x0,
1406+
max_x=target_room.x1,
1407+
min_y=target_room.y0,
1408+
max_y=target_room.y1,
1409+
)
1410+
split_line = calculate_split_line(room_bbox, direction, ratio)
1411+
1412+
# Create virtual state and add edit
1413+
virtual_state = VirtualState(map_data, transformer)
1414+
edit = SplitRoomEdit(
1415+
segment_id=target_room_id,
1416+
x1=split_line.p1.x,
1417+
y1=split_line.p1.y,
1418+
x2=split_line.p2.x,
1419+
y2=split_line.p2.y,
1420+
)
1421+
1422+
success, error = virtual_state.add_edit(edit)
1423+
if not success:
1424+
click.echo(f"Failed to create edit: {error}")
1425+
return
1426+
1427+
click.echo(f"Created split edit for room '{room}':")
1428+
click.echo(f" Line: ({edit.x1:.0f}, {edit.y1:.0f}) -> ({edit.x2:.0f}, {edit.y2:.0f})")
1429+
click.echo(f" Edit ID: {edit.edit_id}")
1430+
1431+
# Preview mode - don't execute yet
1432+
click.echo("\nUse --apply flag to execute (not yet implemented)")
1433+
1434+
1435+
@session.command()
1436+
@click.option("--device_id", required=True, help="Device ID")
1437+
@click.option("--rooms", required=True, help="Comma-separated room names to merge")
1438+
@click.pass_context
1439+
@async_command
1440+
async def merge_rooms(ctx, device_id: str, rooms: str):
1441+
"""Merge multiple rooms into one."""
1442+
from roborock.map import (
1443+
CoordinateTransformer,
1444+
MergeRoomsEdit,
1445+
VirtualState,
1446+
)
1447+
1448+
context: RoborockContext = ctx.obj
1449+
device_manager = await context.get_device_manager()
1450+
device = await device_manager.get_device(device_id)
1451+
1452+
if device.v1_properties is None:
1453+
click.echo("Device does not support V1 protocol")
1454+
return
1455+
1456+
map_trait = device.v1_properties.map_content
1457+
await map_trait.refresh()
1458+
1459+
if not map_trait.map_data:
1460+
click.echo("No map data available")
1461+
return
1462+
1463+
map_data = map_trait.map_data
1464+
room_names = [r.strip() for r in rooms.split(",")]
1465+
1466+
# Find room IDs
1467+
segment_ids = []
1468+
for room_name in room_names:
1469+
found = False
1470+
for room_id, r in (map_data.rooms or {}).items():
1471+
name = getattr(r, "name", f"Room {room_id}")
1472+
if name.lower() == room_name.lower():
1473+
segment_ids.append(room_id)
1474+
found = True
1475+
break
1476+
if not found:
1477+
click.echo(f"Room '{room_name}' not found")
1478+
return
1479+
1480+
transformer = CoordinateTransformer.from_map_data(map_data)
1481+
virtual_state = VirtualState(map_data, transformer)
1482+
edit = MergeRoomsEdit(segment_ids=segment_ids)
1483+
1484+
success, error = virtual_state.add_edit(edit)
1485+
if not success:
1486+
click.echo(f"Failed to create edit: {error}")
1487+
return
1488+
1489+
click.echo(f"Created merge edit for rooms: {room_names}")
1490+
click.echo(f" Segment IDs: {segment_ids}")
1491+
click.echo(f" Edit ID: {edit.edit_id}")
1492+
1493+
1494+
@session.command()
1495+
@click.option("--device_id", required=True, help="Device ID")
1496+
@click.option("--room", required=True, help="Room name")
1497+
@click.option("--new-name", required=True, help="New room name")
1498+
@click.pass_context
1499+
@async_command
1500+
async def rename_room(ctx, device_id: str, room: str, new_name: str):
1501+
"""Rename a room."""
1502+
from roborock.map import (
1503+
CoordinateTransformer,
1504+
RenameRoomEdit,
1505+
VirtualState,
1506+
)
1507+
1508+
context: RoborockContext = ctx.obj
1509+
device_manager = await context.get_device_manager()
1510+
device = await device_manager.get_device(device_id)
1511+
1512+
if device.v1_properties is None:
1513+
click.echo("Device does not support V1 protocol")
1514+
return
1515+
1516+
map_trait = device.v1_properties.map_content
1517+
await map_trait.refresh()
1518+
1519+
if not map_trait.map_data:
1520+
click.echo("No map data available")
1521+
return
1522+
1523+
map_data = map_trait.map_data
1524+
1525+
# Find room
1526+
target_room_id = None
1527+
old_name = None
1528+
for room_id, r in (map_data.rooms or {}).items():
1529+
name = getattr(r, "name", f"Room {room_id}")
1530+
if name.lower() == room.lower():
1531+
target_room_id = room_id
1532+
old_name = name
1533+
break
1534+
1535+
if target_room_id is None:
1536+
click.echo(f"Room '{room}' not found")
1537+
return
1538+
1539+
transformer = CoordinateTransformer.from_map_data(map_data)
1540+
virtual_state = VirtualState(map_data, transformer)
1541+
edit = RenameRoomEdit(
1542+
segment_id=target_room_id,
1543+
new_name=new_name,
1544+
old_name=old_name or "",
1545+
)
1546+
1547+
success, error = virtual_state.add_edit(edit)
1548+
if not success:
1549+
click.echo(f"Failed to create edit: {error}")
1550+
return
1551+
1552+
click.echo(f"Created rename edit: '{old_name}' -> '{new_name}'")
1553+
1554+
1555+
@session.command()
1556+
@click.option("--device_id", required=True, help="Device ID")
1557+
@click.option("--x1", type=int, required=True, help="Wall start X (mm)")
1558+
@click.option("--y1", type=int, required=True, help="Wall start Y (mm)")
1559+
@click.option("--x2", type=int, required=True, help="Wall end X (mm)")
1560+
@click.option("--y2", type=int, required=True, help="Wall end Y (mm)")
1561+
@click.pass_context
1562+
@async_command
1563+
async def add_virtual_wall(ctx, device_id: str, x1: int, y1: int, x2: int, y2: int):
1564+
"""Add a virtual wall."""
1565+
from roborock.map import (
1566+
CoordinateTransformer,
1567+
VirtualState,
1568+
VirtualWallEdit,
1569+
)
1570+
1571+
context: RoborockContext = ctx.obj
1572+
device_manager = await context.get_device_manager()
1573+
device = await device_manager.get_device(device_id)
1574+
1575+
if device.v1_properties is None:
1576+
click.echo("Device does not support V1 protocol")
1577+
return
1578+
1579+
map_trait = device.v1_properties.map_content
1580+
await map_trait.refresh()
1581+
1582+
if not map_trait.map_data:
1583+
click.echo("No map data available")
1584+
return
1585+
1586+
map_data = map_trait.map_data
1587+
transformer = CoordinateTransformer.from_map_data(map_data)
1588+
virtual_state = VirtualState(map_data, transformer)
1589+
1590+
edit = VirtualWallEdit(x1=float(x1), y1=float(y1), x2=float(x2), y2=float(y2))
1591+
1592+
success, error = virtual_state.add_edit(edit)
1593+
if not success:
1594+
click.echo(f"Failed to create edit: {error}")
1595+
return
1596+
1597+
click.echo(f"Created virtual wall edit: ({x1}, {y1}) -> ({x2}, {y2})")
1598+
1599+
1600+
@session.command()
1601+
@click.option("--device_id", required=True, help="Device ID")
1602+
@click.option("--x1", type=int, required=True, help="Zone min X (mm)")
1603+
@click.option("--y1", type=int, required=True, help="Zone min Y (mm)")
1604+
@click.option("--x2", type=int, required=True, help="Zone max X (mm)")
1605+
@click.option("--y2", type=int, required=True, help="Zone max Y (mm)")
1606+
@click.pass_context
1607+
@async_command
1608+
async def add_no_go_zone(ctx, device_id: str, x1: int, y1: int, x2: int, y2: int):
1609+
"""Add a no-go zone."""
1610+
from roborock.map import (
1611+
CoordinateTransformer,
1612+
NoGoZoneEdit,
1613+
VirtualState,
1614+
)
1615+
1616+
context: RoborockContext = ctx.obj
1617+
device_manager = await context.get_device_manager()
1618+
device = await device_manager.get_device(device_id)
1619+
1620+
if device.v1_properties is None:
1621+
click.echo("Device does not support V1 protocol")
1622+
return
1623+
1624+
map_trait = device.v1_properties.map_content
1625+
await map_trait.refresh()
1626+
1627+
if not map_trait.map_data:
1628+
click.echo("No map data available")
1629+
return
1630+
1631+
map_data = map_trait.map_data
1632+
transformer = CoordinateTransformer.from_map_data(map_data)
1633+
virtual_state = VirtualState(map_data, transformer)
1634+
1635+
edit = NoGoZoneEdit(x1=float(x1), y1=float(y1), x2=float(x2), y2=float(y2))
1636+
1637+
success, error = virtual_state.add_edit(edit)
1638+
if not success:
1639+
click.echo(f"Failed to create edit: {error}")
1640+
return
1641+
1642+
click.echo(f"Created no-go zone edit: ({x1}, {y1}) -> ({x2}, {y2})")
1643+
1644+
13401645
def main():
13411646
return cli()
13421647

roborock/map/__init__.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,55 @@
1-
"""Module for Roborock map related data classes."""
1+
"""Module for Roborock map related data classes and editing."""
22

3+
from .editor import (
4+
EditObject,
5+
EditStatus,
6+
EditType,
7+
MergeRoomsEdit,
8+
NoGoZoneEdit,
9+
RenameRoomEdit,
10+
SplitRoomEdit,
11+
VirtualState,
12+
VirtualWallEdit,
13+
)
14+
from .geometry import (
15+
BoundingBox,
16+
CoordinateTransformer,
17+
LineSegment,
18+
Point,
19+
Polygon,
20+
calculate_split_line,
21+
line_intersects_box,
22+
)
323
from .map_parser import MapParserConfig, ParsedMapData
24+
from .translation import TranslationLayer, TranslationResult
25+
from .verifier import MapVerifier, VerificationResult
426

527
__all__ = [
28+
# Map parsing
629
"MapParserConfig",
30+
"ParsedMapData",
31+
# Geometry
32+
"Point",
33+
"LineSegment",
34+
"BoundingBox",
35+
"Polygon",
36+
"CoordinateTransformer",
37+
"calculate_split_line",
38+
"line_intersects_box",
39+
# Editor
40+
"EditType",
41+
"EditStatus",
42+
"EditObject",
43+
"VirtualWallEdit",
44+
"NoGoZoneEdit",
45+
"SplitRoomEdit",
46+
"MergeRoomsEdit",
47+
"RenameRoomEdit",
48+
"VirtualState",
49+
# Translation
50+
"TranslationLayer",
51+
"TranslationResult",
52+
# Verification
53+
"MapVerifier",
54+
"VerificationResult",
755
]

0 commit comments

Comments
 (0)