Saved Zones β Named, Human-Semantic Clean Regions¶
Status: Waves 1 + 2 + 3 BUILT + adversarially reviewed β W1 storage + CRUD + read; W2 filing
(room_number at β₯90%-of-floor via zone_membership) + area_m2 + the set_saved_zone_room
override; W3a clean_saved_zone dispatch (resolve zone β bbox rect β the shared
dispatch_zone_clean, active-map-guarded, fire-and-forget). 2795 tests green, 2026-07-02.
Wave 3b (the card UX: drawβsave, room-grouped multi-select list, pick-and-clean, delete) is
BUILT/SHIPPED across src/{state,renderers,bindings,actions}/saved-zones.js. This doc is the
contract; each wave is additive.
Scope: persist named, reusable zones on a map ("the couch", "the stove", "under the table") that a user can draw once, name, bucket under a room, and re-clean on demand. It is a thin persistence + UX layer over infrastructure that already exists β deliberately not a new map/geometry/dispatch subsystem.
1. Why (the value)¶
Humans navigate by landmark, not coordinate or even room. "Clean under the couch" / "by the stove after dinner" is how people actually reference a spot. A saved zone is a named, human- semantic handle on a sub-room location β more precise than room-level ("clean the kitchen"), reusable (draw once, keep forever), and organized the way a person thinks (by furniture, under the room it lives in). That naming is the whole point; the geometry is just its anchor.
2. What already exists (this is a delta, not a build)¶
Per the scan-first rule, the load-bearing pieces are already standing β saved zones reuses them and adds only persistence + UI:
| Need | Already provides it | Source |
|---|---|---|
| Stable, drift-immune frame | map_state_source normalized 0β1 (provider's own segmentation, not the drifting pose) |
map-state-source.md, 12 β Where the Map Comes From |
| Zone size (mΒ²) | per-room area_m2 / width_m / height_m from map resolution β same math for a drawn box |
map_source.build_map_source_result |
| Named, per-map, user-authored collection storage | custom_layouts: dict[id, CustomLayout] on the map bucket + _migrate_custom_layouts + summaries + create/rename/delete_custom_layout services |
data-model.md Β§Segment stores, mapping/mapping_services.py::_migrate_custom_layouts / ::_create_layout / ::_handle_create_custom_layout |
| Room-membership of a point/region | the room_pixels room-id raster (decoded with map_render rid_shift/catch_all_rid) that backs current_room; per-point analog = the fork's room_at_point (#139) |
map_source.py, fork room_id_at_normalized |
| Fire a zone clean | normalized_rects_to_quads_cm β zone-clean dispatch (Eufy SelectZonesClean via fork #138; Roborock app_zoned_clean, shipped v1.2.0) |
[project_roborock_zone_clean], fork commands.py |
Net new work = a saved_zones sibling collection + a bucketing computation + card UX. No new
coordinate frame, no new size math, no new dispatch.
3. Data model¶
A new per-map collection on the map bucket (data["maps"][v][m]), mirroring custom_layouts
exactly β same id-gen, same lazy _migrate_saved_zones(map_bucket) guard, same
summary-in-snapshot pattern:
SavedZone = {
"id": str, # "sz_<YYYYMMDDTHHMMSS>_<seq>" (_generate_saved_zone_id;
# monotonic seq, ignores deletions)
"name": str, # USER string ("the couch") β data, never i18n (see Β§7);
# stripped, "Zone" fallback if empty at mint
"geometry": [[x,y],...], # normalized 0β1 point list (min 3 points) β THE zone;
# dispatch works off this ALONE. Each coord is schema-
# sanitized: non-numeric/bool/NaN/inf rejected, clamped
# to 0β1, rounded to 5 dp (_saved_zone_coord)
"area_m2": float | None, # computed from map dims at author time (display + validation);
# None until computed β self-healed on read (Β§5, backfill)
"room_number": int | None, # FILING ONLY (Β§4): auto-set at author by β₯90% dominance, else
# None ("Unassigned"); user-editable; NEVER affects dispatch
"kind": str, # "clean" (default, and the only value the schema
# currently accepts -- neither clean handler reads
# kind, so a wider value would silently dispatch as
# a clean anyway; RP-032/A6-ZONE-C-7)
"created_at": str, # ISO UTC at mint (utc_now_iso)
"updated_at": str, # ISO UTC, touched by rename / set-room / backfill
}
There is no stored map-version key β the Β§10 invalidation described below is design intent,
not an implemented mechanism (grep confirms map_version appears nowhere in
custom_components/ or src/).
There is deliberately no persisted rooms[] breakdown and no separate room_override β the
dominance % is a transient author-time compute, and under the Β§10 invalidation intent (a re-map
would invalidate the zone, were that wired up) there is no re-compute that a stored override would
need to survive. SavedZone is lighter than a CustomLayout β one box + name, not a whole
segmentation.
Brand-agnostic: it mirrors the custom-layout storage pattern but is not part of the Eufy
CV/segmentation system; it feeds the per-brand zone-clean dispatch and works on Roborock too.
4. Room grouping β a filing concern, never a dispatch one¶
The key clarification (the leaves case forced it): a saved zone is fundamentally just its
geometry. Dispatch cleans that quad directly (Β§6) and does not care what room(s) it crosses β
a cross-room footprint cleans identically to a single-room one. So room_number is purely
organizational: it decides where the zone appears in the card list, nothing more. It never gates,
splits, or shapes the clean. There is no multi-room tagging and no partition math β dispatch was
never in question, which is exactly why a zone clean (vs. a room clean) is valuable: it addresses
"this specific footprint" regardless of room boundaries.
Motivating case: leaves blow into the entryway and a bit of the adjoining office. You want a single job over that footprint β the whole entryway + a corner of the office β not the whole office cleaned. The zone crosses a room boundary on purpose; dispatch just cleans the box.
Two populations (design rationale β split is an expected minority, not a rare afterthought and not a heavy feature):
- Landmark zones (majority) β "in front of the couch", "outside the cat room". Single-room by construction (a landmark lives somewhere), so β₯90% dominance auto-assigns correctly and rarely needs touching.
- Task-footprint zones (a known minority) β deliberately cross a soft/user-drawn boundary because the task ignores rooms: the leaves case, "along this whole wall". These land in Unassigned and get filed by hand or just left there.
Auto-assign (room_number, filing only): sample the room_pixels room-id raster over the
zone's box; denominator = segmented floor pixels only (furniture footprints β the couch's body,
the Eufy bg rid β excluded, so a big-furniture zone isn't wrongly split). One room holds β₯ 90%
of the floor β room_number = that room; otherwise room_number = None β Unassigned. Computed
once at author time β no persisted breakdown, no re-compute.
Editable: room_number is user-settable any time (a room picker; None = Unassigned) β the same
machine-guesses / human-files pattern as phantom-room curation and the external-run review wizard.
The majority never need it; the task-footprint minority get filed (or left) in one tap. Editing is a
filing action with zero effect on the clean.
5. Services (mirror the custom-layout trio)¶
In mapping/mapping_services.py, mirroring _handle_{create,rename,delete}_custom_layout:
create_saved_zoneβ(vacuum_entity_id, map_id, name, geometry[, kind])β stores, then best-effort computesarea_m2+room_number(β₯90%-of-floor dominance, filing only; never fails the create), returns{saved, zone_id, zone}. Create-time refuse gates: blank name βmissing_name; unknown map βmap_not_found(+known_maps); and the schema itself rejects a degenerate geometry β a bbox with either side< 0.01normalized (_reject_degenerate_zone_geometry, the same_MIN_SIDEasdispatch_zone_clean's own guard, so a zone that saves can never fail degenerate at clean time; RP-032/A1-SERVIC-4). The membership compute is active-map-guarded: it runs when the active map matches the zone'smap_idor is indeterminate; a definite mismatch leavesarea_m2/room_numberNone. Un-sized zones are self-healed on theget_map_segmentsread (_backfill_saved_zone_area: same guard, sizes + files anyarea_m2 is Nonezone, persists).rename_saved_zoneβ(β¦, zone_id, name).delete_saved_zoneβ(β¦, zone_id).set_saved_zone_roomβ(β¦, zone_id, room_number | null)β sets/clearsroom_number(filing only; null = Unassigned; no effect on dispatch).clean_saved_zoneβ(β¦, zone_id, clean_times?)β takes the geometry's normalized bbox rect and fires the shareddispatch_zone_clean(which owns the per-brand coordinate conversion, Β§6) at call time.clean_timesis the optional number of cleaning passes (min 1) β but Eufy declaressupports_zone_repeat: false, so anyclean_times > 1there is normalized to 1 with a logged warning, not honored (dispatch/manager.py, the non-device_mmbranch). Clean-time refuse gates:zone_not_found; the active-map guard β a zone's geometry is only valid on ITS map, so a different active map refuses withmap_not_active(+active_map_id), and an unreadable/indeterminate active map also refuses (active_map_indeterminateβ indeterminate β match, RP-029/ZONE-C-1);bad_geometry(< 3 valid points). Fire-and-forget: no job/queue/learning.clean_saved_zonesβ(vacuum_entity_id, map_id, zone_ids[], clean_times?)β resolves every zone to its bbox rect and fires the whole set as ONEdispatch_zone_cleancall (same active-map + indeterminate refusals + Eufy repeat-normalization above; per-brand caps enforced inside the dispatch: Eufy up to 10 zones / 0.5β10 m per side, Roborock up to 5 zones / 1 ftΒ²β3.05 mΒ² each; JS wrappercleanSavedZones). Atomic: any missing zone (zone_not_found+ the id list) or bad-geometry zone (bad_geometry+ the id list) refuses the whole batch.
Each runs _migrate_saved_zones first, degrades safely, and the snapshot carries a
full saved-zone dict per zone on the get_map_segments read (geometry included β NOT a summary, unlike the sibling custom_layouts, which is); no SavedZoneSummary shape exists anywhere in the code.
6. Dispatch + drift-safety (the one hard rule)¶
Store normalized, convert at clean-time β never persist absolute cm. The provider re-origins
its coordinate frame per session; the map-relative (normalized) frame is stable, but a cached
device-coordinate quad is not. So clean_saved_zone/clean_saved_zones hand the stored 0β1 bbox
rect(s) to the shared dispatch_zone_clean (dispatch/manager.py::dispatch_zone_clean) each time, which converts
per brand, at call time, from the current session's live map, exactly as live zone-drawing
already does β never persist absolute coordinates. The conversion itself is brand-branched on the
adapter's zone_coords capability, not a single shared helper:
zone_coords: device_mm(Roborock) β converted to world millimetres viazone_dispatch.normalized_rects_to_mm, usingmap_source_runtime.correspondences_from_mapdata; a validation failure refuses the dispatch rather than risk cleaning the wrong area.- Everything else (Eufy) β the 0β1 rects ship verbatim; the fork de-normalizes them on its
own side (
SelectZonesClean). Size-bound checks (when the adapter declares any) still convert to metres locally for validation only, using the live map's ownwidth/height/resolution(the same math asmap_source.zone_membership's footprint calc) β that conversion never leaves the service, the dispatched payload stays normalized.
Saved zones must not shortcut this by caching a converted quad.
Map-flip property (hypothesis to validate β Chris, 2026-07-02): because a saved zone is stored
map-relative and converted at call time from the current map's geometry β which updates ~0.5s
after a MAP_LOAD, well before the pose re-localizes β a saved zone may dispatch correctly right
after a map switch, when live zone drawing cannot (drawing needs the live grounded frame the
switch un-grounds). If so, saved zones are the reliable post-switch clean path, and firing one
likely forces re-localization itself (like the room-based refresh probe). Test once the fork
map_load primitive lands (fork PR #150) β see the map-switcher notes in reference_eufy_biz_map_switch.
7. i18n¶
Zone names are user data, not authored strings β "the couch" is exactly like a user's room name ("Kids Bedroom"): we store and display it verbatim, never fabricate or translate it. So it satisfies the no-string-without-i18n contract the same way room names do (display is identity). Only the card chrome (buttons, section headers, the "Unassigned" section label, validation messages) routes through i18n.
8. Card UX¶
- Author: draw a box on the map β name it β save (
create_saved_zone). No live mΒ² while drawing βarea_m2is computed server-side at create (_handle_create_saved_zoneβzone_membership) and renders only on an already-saved zone; the size/ count limits gate here (Β§9). - Browse: the saved-zone list, grouped by
room_number(room sections in map order, the Unassigned special-cases section last). Each entry shows name + mΒ². - Reassign: a room picker per zone (
set_saved_zone_room) β a filing action only; None = Unassigned. - Clean: tapping a zone row toggles its multi-select checkbox, and the singular
clean_saved_zonehas NO card caller (it is a registered service + thecleanSavedZoneJS wrapper, reachable from an automation). The card's one clean control, "Clean N selected", always fires theclean_saved_zonesbatch (respecting the zone cap).
9. Validation (author-time + clean-time)¶
- Author time: the create schema rejects bad coords (
_saved_zone_coord) and a degenerate bbox (either side< 0.01normalized β_MIN_SIDE, deliberately equal to the dispatch-side guard so nothing saves that can't clean); the card caps drawn boxes at the brand zone cap (snapshotzone_maxviazoneMax(), fallback 10 βsrc/state/map.js). - Clean time:
dispatch_zone_cleanre-checks degenerate rects and enforces the per-brand capability caps β zone count (zone_max) plus per-zone size bounds (zone_min_area_m2/zone_max_area_m2/zone_min_side_m/zone_max_side_m; absent = unconstrained for that brand). Eufy: 10 zones, 0.5β10 m per side. Roborock: 5 zones, 1 ftΒ²β3.05 mΒ² area. There is no auto-tiling of an oversize zone β it refuses with an error.
10. Invalidation¶
Design intent, NOT yet implemented: the intent is that a genuine re-map (the
room_pixels raster changes meaning) invalidates the map's saved zones. Today no code does
this β the record stores no map-version key (Β§3) and no writer CLEARS saved_zones on a
re-map (maps/map_manager.py:rebuild_map_bucket rewrites rooms/summary/metadata only; the other
writers β _create_saved_zone, _backfill_saved_zone_area, _handle_rename_saved_zone,
_handle_set_saved_zone_room, _handle_delete_saved_zone β insert, mutate, or remove individual
zone entries, none of them invalidate the collection on a re-map), so zones survive a re-map with
silently re-interpreted geometry. The active-map guard (Β§5) only protects against cleaning while a
different map is loaded, not against the same map being re-mapped. Within an unchanged map,
zones + overrides are stable across sessions (the normalized frame doesn't drift).
11. Waves (each shippable, additive)¶
- Storage + services β
saved_zonescollection,_migrate_saved_zones, create/rename/delete, snapshot summary. No bucketing yet (flat list). - Bucketing β the room-mask membership computation (90%-of-floor),
room_numberonly (norooms[]breakdown is persisted β Β§3),set_saved_zone_roomoverride. - Card UX (BUILT/SHIPPED) β drawβnameβsave (via
_zoneDrawPurpose="save"), room-grouped multi-select list, room-picker, clean (ONE "Clean N selected" batch control β selecting a single zone IS the single-zone clean; the card never calls the singularclean_saved_zone), delete, mΒ² + validation. Implemented acrosssrc/{state,renderers,bindings,actions}/saved-zones.jsand registered in each layer'sindex.js; the panel renders in the Rooms view (renderers/rooms.jscallsrenderSavedZonesPanel).
12. Non-goals / open¶
- Not map editing (no-go zones, virtual walls) β the app owns that.
- Not voice ("clean under the couch") β that's the back-burnered wizard; saved names would be a natural vocabulary if it's ever revived, but no dependency here.
- ~~Confirm the per-brand zone limits (count + mΒ²) and whether Roborock exposes the same size
bounds as Eufy.~~ RESOLVED β declared per adapter (capabilities:
zone_max+ thezone_min/max_area_m2/zone_min/max_side_mquartet) and enforced indispatch_zone_clean(Β§9); Eufy caps per side, Roborock per area. - ~~Decide the exact snapshot shape of
SavedZoneSummary(heavygeometryin or out).~~ RESOLVED (W1): the full zone (geometry included) rides in theget_map_segmentsread β a point list is negligible beside the base64 map image already in that payload (the "unbounded points" bloat concern was raised in review and refuted). Nomin/maxpoint cap (the sibling segment/primitive stores are unbounded too).
Wave 1 review outcome (2026-07-02): 3-lens adversarial review β 2 confirmed findings, both the
same class β the geometry schema accepted non-finite (NaN/inf, which orjson silently nulls on
save) and out-of-range coords. Fixed by _saved_zone_coord: reject non-numeric/bool/non-finite
(fail loud), clamp finite to 0-1, round β mirroring _handle_set_hidden_regions. Locked by a test.
Wave 2 (2026-07-02): zone_membership (pure raster tally in map_source.py, reusing
normalize_rendered/point_in_polygon, but NOT _area_m2 β the footprint is its own bbox-span math,
offset-independent so "size shown == size cleaned") + async_get_map_data_dict (coordinator accessor,
memoryβ.storage, Eufy-only, degrade-to-None) + best-effort compute in create_saved_zone +
set_saved_zone_room filing override. Review β 1 finding (map-mismatch): the compute read the
current map's raster with no check it matched the zone's map_id β could file wrong-map
membership on a multi-map device. Fixed: compute only when the active map
(get_active_map_id) matches map_id or is indeterminate; a definite mismatch leaves None
β mirroring the dispatch-path map_mismatch guard. (Note the asymmetry vs the CLEAN path, which
refuses on indeterminate rather than proceeding β RP-029: a wrong filing is recoverable by
re-editing room_number, a wrong clean is not.) Locked by a test. (LOW severity β unreachable
via the card's author-on-active-map flow + the fork can't read a non-active raster until PR #150,
but the guard makes it correct-by-construction.)
Cross-links¶
- map-state-source.md / 12 β Where the Map Comes From β the frame + size + room-mask this reuses.
- data-model.md β the map bucket + the
custom_layoutspattern this mirrors.