11 β Mapping System¶
This document covers the mapping subsystem: image segment analysis, native current-room tracking, segment adjustments, custom layouts, the provider map source, and its storage. It is intended to expose the full algorithms and mathematics so a developer can understand, modify, or port the system.
Status (mapping split). The run-derived inference lineage β trace capture / segmentation / review, room-boundary derivation (vacuum-space polygons), the affine transform-fitting pipeline, image-segment suggestion, and the learned per-room bounding-box store (
room_bounds.py/RoomBoundsStore) itself β was retired. Room tracking now runs off the device's native current-room signal: the tracker resolves it and fireseufy_vacuum_room_completed(with each room's dwell duration) via a confidence/dwell debounce, and the map card's companion homes off the live map source'scurrent_roomβ nothing runs in drifting vacuum-coordinates anymore. What survives is the CV segmentation pipeline (Β§2), segment adjustments (Β§5), image variants (Β§6), custom layouts (Β§10), and the provider map source (Β§11). The retired designs are preserved in git history and the boundary-derivation design note; Β§3, Β§7, and the retired-storage notes in Β§8 below are kept only as historical reference β their code is deleted.
1. Overview¶
The mapping system has three subsystems that address different aspects of knowing where rooms are. Two are local: the first derives room shape from a map PNG (pixel space); the second tracks which room the robot is in from the device's native current-room signal (this replaced the retired learned-bounds approach β Β§3). The third reads the room layout the provider's own firmware already knows.
Image segment analysis answers: given a map PNG, what regions probably correspond to rooms? It is a pure computer-vision pipeline that works from pixel data alone. The brand-agnostic geometry/image primitives live in mapping/segment_primitives.py; the Eufy HSV pipeline that uses them lives in adapters/eufy/segmentor.py and is invoked through the segmenter-engine abstraction in mapping/segmenter_engines.py. Its outputs are polygons in pixel space, labelled with quality signals (confidence, structural role, issues). These polygons are used as room shape overlays in the UI map card.
Room tracking (tracker.py) answers: which room is the robot in right now? The tracker reads the device's native current-room signal (the adapter's active_cleaning_target), resolves it to a managed room, and β through a confidence/dwell debounce β fires eufy_vacuum_room_completed with each room's dwell duration as the robot moves between rooms. The earlier learned bounding-box approach (run-derived, in vacuum coordinates) was removed: the device re-bases its coordinate origin every session, so cross-session bounds drifted (see Β§3, kept as historical reference).
Provider map source (map_state_source) answers: what room layout does the provider's own firmware already know? Rather than deriving rooms from pixels (image analysis) β or, as the retired trace-bounds learning once did (Β§3), inferring them from drifting robot samples β this reader normalises the device's authoritative segmentation β per-room bbox/name plus dock/robot anchors and (later waves) area, current room, and overlay layers β into VA-owned room data in a single rendered-image-normalised (0β1) coordinate space. It is covered in Β§11 and documented in full in the design reference dev/map-state-source.md.
The image-segment pipeline and the native-current-room tracker are independent. There is no runtime transform between pixel space and vacuum space: the legacy "System A" affine vacuumβpixel transform was removed along with the bounds derivation.
- Room-boundary derivation (vacuum-space polygons learned from a robot boundary-trace trail) and the learned per-room bounding-box store were both retired with the mapping split (Β§3). Room presence now comes straight from the device's native current-room signal β nothing is learned in drifting vacuum-coordinates.
- The image-segment polygon gives room shape in pixel space; it is used only as a UI map-card overlay and is never projected into vacuum space.
- Because no pixelβvacuum transform exists, an image-space polygon can never be tested against a live (vacuum-space) position β the two spaces are never mixed.
2. Image Segment Analysis Pipeline¶
The public entry point is detect_room_segments(...) in adapters/eufy/segmentor.py, which delegates to _detect_room_segments_pipeline(...). It is built on the brand-agnostic primitives in mapping/segment_primitives.py and is reached at runtime via the EufyCVSegmenter wrapper in mapping/segmenter_engines.py (the segmenter-engine abstraction that lets a non-Eufy brand swap in its own pipeline).
2.1 Input¶
detect_room_segments(
image_path: str, # primary map PNG (dark or default variant preferred)
expected_room_count: int | None, # hint β enables count-deficit recovery pass
max_segments: int | None, # hard cap on output segment count
min_area_pixels: int = 1200, # minimum region area to consider
simplify_epsilon: float | None, # RDP polygon simplification tolerance
assist_image_path: str | None, # secondary image variant (light) for wall cutting
image_variant: str | None, # metadata label, e.g. "dark"
assist_variant: str | None, # metadata label, e.g. "light"
)
The caller (_handle_analyze_map_image in mapping_services.py) selects the primary image with preference order dark β default, and uses light as the assist if present.
2.2 Room Pixel Mask β HSV Thresholds¶
The first step converts the RGB image to HSV (Pillow "HSV" mode, uint8 [0β255] for all channels) and applies two thresholds to produce a binary room-pixel mask:
Pixels must be both bright enough (value β₯ 68/255 β 27% brightness) and colourful enough (saturation β₯ 18/255 β 7%) to be treated as room pixels. This excludes near-black walls, near-white backgrounds, and the off-white dock area.
Morphological cleanup is applied after thresholding:
binary_opening(3Γ3, 1 iteration) β removes isolated noise specks.binary_closing(3Γ3, 2 iterations) β bridges small gaps within rooms.binary_fill_holesβ fills enclosed background pockets.
The result is base_room_mask (saved for diagnostics).
2.3 Assist Image Alignment and Wall Cutting¶
When an assist image is provided, the pipeline:
- Builds a room mask from the assist image using the same HSV thresholds.
- Estimates alignment between the two room masks via
_estimate_alignment, which is a brute-force IoU grid search over scales[0.94, 0.97, 1.0, 1.03, 1.06]and pixel shifts[-24, -18, ..., +24]on each axis (step 6), finding the transform that maximises intersection-over-union. - Warps the assist image (mask, RGB, H, S, V channels separately) into the primary image's coordinate frame using the found scale/shift.
- Builds a wall mask from the assist image using the near-white threshold:
value >= 214andsaturation <= 36. - Restricts wall cuts to a seam zone: pixels within a dilated edge band of
base_room_mask. This prevents broad wall-mask subtraction from erasing valid room pixels β only the seam between regions is cut. - Applies:
room_mask = base_room_mask & ~seam_wall_mask, then re-applies opening/closing/fill.
The result is a room mask with wall-like seam pixels removed where the two images agree.
2.4 Hue Clustering¶
The pipeline works on hue because Eufy map images assign distinct colours to each room. The smoothed hue image is:
hue_smooth = median_filter(hue, size=5)
hue_bin = ((hue_smooth.astype(int16) + 8) // 16).astype(int16)
This quantises the 0β255 hue range into 16-unit bins (bins 0β15). The +8 centres each bin on its midpoint. All distinct bin values present in the room mask become separate clusters. There is no fixed number of clusters β the set is derived entirely from what hue values are present.
For each cluster (hue bin), the pipeline:
- Extracts the cluster mask:
room_mask & (hue_bin == bin_index). - Applies
binary_closing(5Γ5, 2 iterations) to merge nearby fragments of the same hue, thenbinary_opening(3Γ3, 1 iteration) andbinary_fill_holes. - Calls
scipy.ndimage.labelwith an 8-connected structure to find connected components.
2.5 Per-Component Metrics¶
For each connected component the following are computed:
| Metric | Formula |
|---|---|
area |
count_nonzero(component_mask) |
area_percent |
area / (width Γ height) |
fill_ratio |
area / (bbox_width Γ bbox_height) |
perimeter |
count of exposed 4-connected edges |
compactness |
(4Ο Γ area) / perimeterΒ² (1.0 = circle, lower = irregular) |
aspect_ratio |
max(w,h) / min(w,h) |
agreement_score |
fraction of component pixels confirmed by the aligned assist mask |
Issue flags are set based on these metrics:
- tiny_region: area_percent < 0.015 and agreement_score < 0.5
- touches_border: component bbox is within 1 pixel of the image edge
- possible_merge: fill_ratio < 0.42 β suggests two rooms merged at a doorway
- oversized_region: area_percent > 0.45
- fragmented_candidate: compactness < 0.08 and area_percent < 0.02
2.6 Cascade Splitting Algorithm¶
A component is flagged as a suspicious merge when:
area >= max(4 Γ min_area_pixels, 5200) and (fill_ratio < 0.58 or area_percent > 0.18 or "oversized_region" in issues)
When flagged, _split_suspicious_component attempts these strategies in priority order, stopping at the first success (2+ output masks):
wall_cutsβ dilate the seam wall mask into the component; erode by 1β3 iterations; re-label; propagate seeds back into the full component mask.localized_binsβ only for very large components (area >= max(10 Γ min_area_pixels, 120_000)). Quantises normalised chromaticity features into 7-level bins, ranks bins by size (filtering to[min_area_pixels Γ 0.9, active_area Γ 0.24]range), selects up to 6 colour bins and 4 hue bins as seeds, and grows each seed back into the component.color_distanceβ normalised RGB chromaticity features (illumination-invariant). Finds the most distinct bin pair (requiring Euclidean distance β₯ 0.09 in normalised feature space), seeds each, and propagates. Requiresused_area >= 38%of the active area for acceptance.local_supportβ scores pixels by whether they exceed per-channel percentile thresholds (P42 saturation, P38 value from primary; P40 saturation, P36 value from assist). A pixel needs a combined score β₯ 2 (or 3 with both assist channels). Clusters the high-scoring pixels and propagates back.assist_hueβ bins assist-image hue into 12-unit bins (15 bins total over 0β180). Selects bins with size β₯max(0.9 Γ min_area_pixels, 14% of active), ensuring angular separation β₯ 2 bins (24Β°) between selected bins. Propagates each into the component.erosion_seedsβ progressively erodes (1β4 iterations) until the component splits, then propagates each seed back.opening_splitβbinary_openingat increasing iterations (1β4) to split; propagates back.
Each strategy requires all resulting sub-masks to have area β₯ max(350, 0.45 Γ min_area_pixels).
After splitting via localized_bins, the _reclaim_localized_child_mask function attempts to recover vertically truncated children. It trims sparse top rows (rows with fewer pixels than 42% of the dense-row median), then performs a constrained downward propagation (binary_propagation) from the lower seed band, limited to pixels that pass saturation and value floor thresholds derived from the child mask's own P20 values.
2.7 Polygon Extraction¶
Each kept component mask is converted to a polygon by _mask_to_polygon:
- Builds an edge graph by scanning each
Truepixel for exposed edges (top/right/bottom/left). Each exposed edge becomes a directed half-edge in a hash map. - Traces the outer loop by following the edge graph, preferring angular continuity at junctions.
- Applies Ramer-Douglas-Peucker simplification (
_rdp) with epsilon auto-computed as:
If the raw trace had β₯ 700 points but simplified to β€ 6 points (over-simplified), the epsilon is reduced to max(0.8, epsilon Γ 0.72) and simplification is retried.
The epsilon may also be passed explicitly by the caller.
- If the simplified polygon has fewer than 4 points, the raw trace is used as-is.
2.8 Confidence Scoring¶
Each candidate segment receives a confidence score [0.05, 0.99]:
confidence = 0.9
confidence -= min(non_split_issue_count Γ 0.1, 0.45)
if fill_ratio < 0.55:
confidence -= 0.12
if simplified_point_count > 14:
confidence -= 0.1
confidence += min(agreement_score Γ 0.12, 0.12)
confidence = clamp(confidence, 0.05, 0.99)
This maps to a quality label:
- "poor": has tiny_region or too_complex issue
- "usable": has touches_border or possible_merge, or confidence < 0.55
- "good": confidence < 0.75
- "strong": otherwise
2.9 Post-processing¶
After all components are evaluated:
- Deduplication β segments sorted by area descending; any candidate whose mask overlaps 80% of an already-kept segment (or 55% when centres are within 28 pixels) is dropped.
- Localized-bins deduplication β localized-bins children are separately ranked by
(confidence, fill_ratio, compactness, area)and capped at 4 per parent; siblings with > 35% mutual overlap are dropped. - Count-deficit recovery β if
expected_room_countis set and the segment count is still below it, previously-deferred candidates are re-evaluated inrecovery_mode=True(lower thresholds) and admitted if they pass. Recovered candidates receive therecovered_count_deficitissue tag. max_segmentscap β after sorting by area descending, any segment beyondmax_segmentsis dropped.
2.10 polygon_pct vs polygon_pixel¶
polygon_pixel is the raw polygon in image pixel coordinates: [[x0, y0], [x1, y1], ...].
polygon_pct is computed on the fly in _handle_get_map_segments each time segments are fetched:
polygon_pct = [
[round(x / image_width Γ 100, 4), round(y / image_height Γ 100, 4)]
for x, y in polygon_pixel
]
This converts each vertex to percentage of image dimensions [0β100]. The card uses polygon_pct so overlays scale correctly regardless of the image's display size.
2.11 image_runtime_capabilities¶
image_runtime_capabilities() (in mapping/segment_primitives.py) probes whether each optional library is importable and returns a dict:
{
"numpy": {"available": bool, "version": str|None, "error": str|None},
"pillow": {...},
"scipy": {...},
"scipy_ndimage": {...},
"pipeline_ready": bool, # True if pillow + numpy + scipy.ndimage available
}
The pipeline depends only on Pillow + NumPy + SciPy; pipeline_ready is the single readiness gate. There is no OpenCV or scikit-image dependency.
3. Room Bounds from Traces¶
Removed (mapping split). The learned per-room bounding-box store described below β
room_bounds.py/RoomBoundsStore, the sampleβbounds attribution, and theget_room_bounds_snapshotservice β was deleted. It rode the device's vacuum-coordinate frame, which re-bases every session, so the cross-session bounds were a smear. Room tracking now reads the device's native current-room (Β§1). This section is retained as a historical / disaster-recovery reference; the code is gone.Preserve Β§3.1βΒ§3.6 verbatim β do not trim. This is the sole in-repo record of the traceβbounds design, kept deliberately for a possible future revival. The present tense below describes the retired algorithm, not current behavior; a doc-sweep must not "reconcile" it away. Revival is gated on first proving a stable cross-session coordinate origin (see memory
project_boundary_derivation_dead).
3.1 What a "trace" is¶
A trace is a time-ordered list of vacuum position samples collected during a single cleaning job. Each sample is (vx, vy) in vacuum coordinate units. Samples are collected in MappingTracker._handle_position_update by reading the robot_position_x and robot_position_y HA sensor states.
Deduplication is applied at collection time: if the new (vx, vy) is identical to the most recently recorded position, it is discarded. This prevents the X and Y sensors firing separately on the same movement event from creating double entries.
Sampling pauses during mid-job dock returns (pause_sampling / resume_sampling) to prevent hundreds of identical dock-position samples from corrupting room bounds.
Samples are flushed to a temporary file (_samples_active.json) every 25 unique positions so that an HA restart mid-job can recover the partial run.
3.2 Attribution Strategy¶
At the end of a job, RoomBoundsStore.update_room_bounds attributes samples to rooms:
Single-room job (exactly one non-transition room in the job's room dict): all samples are attributed to that room unconditionally.
Multi-room job: for each sample (vx, vy), the first room whose stored bounding box (expanded by BOUNDS_MARGIN) contains the point receives credit. Rooms with fewer than MULTI_ROOM_MIN_RUNS = 4 active history entries are skipped as attribution anchors because their bounds are not yet reliable enough. Unattributed samples are discarded.
3.3 BOUNDS_MARGIN = 50¶
After attribution, the bounding box query in _update_confidence also uses the same margin:
This adds 50 vacuum units to each side of the stored bounding box when testing whether a position falls within a room. The margin exists to handle two situations: - The robot may clean right up to the boundary of its known box, or slightly beyond it, as the room boundaries are learned incrementally. - Coordinate jitter in the vacuum's reported position means exact bounding-box containment would miss valid cleaning positions near edges.
3.4 Percentile Trimming¶
Before samples are committed to history, _percentile_trim is applied:
def _percentile_trim(samples, p_lo=0.10, p_hi=0.90):
# Requires >= 10 samples (_TRIM_MIN_SAMPLES) to apply trimming.
xs = sorted(vx for vx, _ in samples)
ys = sorted(vy for _, vy in samples)
n = len(xs)
lo_i = int(n * 0.10)
hi_i = min(int(n * 0.90), n - 1)
x_lo, x_hi = xs[lo_i], xs[hi_i]
y_lo, y_hi = ys[lo_i], ys[hi_i]
return [(vx, vy) for vx, vy in samples if x_lo <= vx <= x_hi and y_lo <= vy <= y_hi]
The outermost 10% of both the X and the Y distributions are discarded (independently). A sample survives only if it is within both P10βP90 ranges. This eliminates: - Dock-adjacent outlier coordinates that slip through the pause gate. - Large coordinate excursions caused by the robot leaving a room briefly to navigate. - Sensor glitch spikes that report a physically impossible position for one sample.
Below 10 samples, no trimming is applied because there is insufficient data to compute meaningful percentiles.
3.5 History Cap β 20 Entries¶
Each room's job_bounds_history is capped at 20 entries (newest first):
The newest entry becomes index 0. The oldest survives entry becomes index 19. This is the baseline entry and is protected from manual exclusion (see Section 7). Capping at 20 prevents unbounded file growth while retaining enough history for meaningful outlier detection.
3.6 Bounds Recomputation¶
After every history update, _recompute_bounds_from_history rebuilds the active bounding box:
active = [e for e in history if not e.get("excluded", False)]
min_x = min(e["min_x"] for e in active)
max_x = max(e["max_x"] for e in active)
min_y = min(e["min_y"] for e in active)
max_y = max(e["max_y"] for e in active)
The resulting bounds is the union of all active (non-excluded) job entries. There is no decay or weighting β every active run contributes equally to the envelope. The centroid cx, cy is the midpoint of the union box. run_count is the number of active entries. updated_at is the recorded_at timestamp of the most recently added entry (index 0).
4. Coordinate System¶
4.1 Vacuum Coordinate Space¶
The vacuum reports its position as (vx, vy) values. These are in proprietary integer units originating from the robot's internal SLAM system. The scale is approximately 1 unit β 1 mm for Eufy devices (not verified across all models). The origin and axis directions are robot-specific. On Eufy map_6 the Y axis increases upward in the robot's reference frame but this is not guaranteed by the protocol.
4.2 Pixel Coordinate Space¶
The map PNG uses the standard image convention: origin at the top-left, X increases rightward, Y increases downward.
4.3 No Cross-Space Transform¶
The two coordinate spaces are not related by any runtime transform. The legacy "System A" affine vacuumβpixel transform (calibration pairs, compute_affine_transform, vacuum_to_pixel/pixel_to_vacuum, machine-corner clustering) has been removed.
Consequences for the rest of the system:
- Image-segment polygons stay in pixel space and are used only as UI map-card overlays; they are never projected into vacuum space.
- Vacuum-space position is now consumed only by the tracker's confidence tick and the passive dock-coordinate drift log (Β§4.1); the retired room-bounds store (Β§3) that once accumulated it is gone.
- The two are never mixed: without a pixelβvacuum transform a live (vacuum-space) position cannot be tested against a pixel-space polygon.
5. Segment Adjustments¶
5.1 The Adjustment Record¶
Each map stores a image_segment_adjustments dict keyed by segment_id. Each entry holds:
{
"offset_x": int,
"offset_y": int,
"edge_left": int,
"edge_right": int,
"edge_top": int,
"edge_bottom": int,
"vertex_moves": [{"index": int, "delta_x": int, "delta_y": int}, ...]
}
All values are integers (pixel units). Missing keys are treated as 0.
5.2 The Adjustment Application β _adjust_polygon_pixel¶
When get_map_segments is called, _apply_segment_adjustments applies stored adjustments to each raw segment polygon. The transform proceeds in three layers applied cumulatively:
Layer 1 β whole-shape translation: every vertex is shifted by (offset_x, offset_y).
Layer 2 β edge nudges: vertices that lie in the outermost 10% of the polygon's bounding box width or height receive an additional per-edge shift:
band_x = max(2, int(round(width Γ 0.1)))
band_y = max(2, int(round(height Γ 0.1)))
if x <= (min_x + band_x): x += edge_left
if x >= (max_x - band_x): x += edge_right
if y <= (min_y + band_y): y += edge_top
if y >= (max_y - band_y): y += edge_bottom
This allows the left, right, top, or bottom edges of the polygon to be pushed independently without moving the whole shape.
Layer 3 β vertex deltas: each entry in vertex_moves moves the specific polygon vertex at index by (delta_x, delta_y), applied after the previous two layers.
All three layers are additive and always applied together. There is no matrix composition β the output is a list of [int, int] pixel points.
5.3 Cumulative Adjustment Accumulation¶
The adjust_map_segment service call accumulates adjustments, it does not replace them:
offset_x = current.get("offset_x", 0) + call_delta_x
offset_y = current.get("offset_y", 0) + call_delta_y
edge_left = current.get("edge_left", 0) + call_edge_left
# ... same for all edge fields
For vertex_moves, each (index, delta_x, delta_y) from the call is merged into the existing vertex table by index:
new_delta_x = prev.get("delta_x", 0) + call.get("delta_x", 0)
new_delta_y = prev.get("delta_y", 0) + call.get("delta_y", 0)
If both deltas resolve to zero after merging, the vertex entry is removed. If all fields are zero after accumulation, the entire segment adjustment record is deleted. This means calling the service with negative deltas can undo a previous adjustment.
5.4 Issue Tags¶
When adjustments are applied, the following tags are added to the segment's issues list:
- translated_manual β any adjustment was applied (always set when record is non-zero)
- edge_adjusted_manual β at least one edge field is non-zero
- vertex_adjusted_manual β at least one vertex_moves entry exists
The translation_offset, edge_adjustment, and vertex_adjustment fields are also set on the returned segment for diagnostic display.
6. Map Image Variants¶
The upload_map_image service validates the variant field through the _image_variant validator (default default): it accepts the four fixed keys default | dark | light | custom plus any per-layout key matching custom_* (one backdrop per named custom layout, see Β§10). An unrecognised value raises vol.Invalid.
| Variant key | Role | Used for |
|---|---|---|
"default" |
"primary" |
General display, fallback for analysis |
"dark" |
"segmentation" |
Primary input for image segment analysis; dark theme captures have better colour contrast between rooms |
"light" |
"boundary" |
Assist input for wall cutting; light theme captures have near-white walls that are easy to detect |
"custom" |
"manual" |
Legacy single-custom backdrop and the default backdrop for a migrated "Custom" layout (Β§10). Stored and served like any other variant, but the segmenter never reads it β _handle_analyze_map_image only probes dark/default (primary) and light (assist), so a custom-only map is never auto-segmented. |
"custom_<layout_id>" |
"manual" |
Per-layout backdrop for a named custom layout (Β§10). Written when upload_map_image is called with a layout_id β the server forces the variant key to custom_<layout_id> and repoints that layout's backdrop_variant. Like "custom", never auto-segmented. Its recorded image_width/image_height define the pixel space set_custom_segments rasterises that layout's authored polygons against. |
"custom_<id>_home_art" / "custom_<id>_room_<rid>" |
"manual" |
Furnished-art variant for a live-map layout β a to-scale home render composited over the live map (whole-home, or per-room). Written when upload_map_image is called with art_scope (home/room); points the layout's home_art.art_variant / rooms[<rid>].art_variant, not its backdrop_variant. Display-only (never segmented). See the furnished render reference. |
Variant names are normalised to lowercase, spaces and hyphens replaced by underscores.
Variant priority depends on the segmentation mode and active layout. In cv mode the active backdrop and analysis inputs prioritise dark β default for the primary and light for the assist; the custom backdrops are ignored. In custom mode (Β§10), _handle_get_map_segments resolves the active layout's backdrop_variant (a custom_<layout_id> key, or the shared custom key for a migrated layout) as the active backdrop, falling back to dark β default β light only if no such image is recorded. The segmenter reads dark/default/light exclusively and never reads any custom* variant in either mode.
6.1 Storage Paths¶
Images are written to a single location under the vacuum's map directory, which is served directly to the browser:
- On disk:
<config_dir>/eufy_vacuum/maps/<vacuum_slug>/map_<map_id>[_<variant>].png - Browser URL:
/eufy_vacuum/maps/<vacuum_slug>/map_<map_id>[_<variant>].png
The _<variant> suffix is omitted when variant == "primary" or variant == "default". For dark: map_6_dark.png. For light: map_6_light.png. For default/primary: map_6.png.
Image metadata (width, height, path, browser_url) is recorded in the map bucket's image_variants dict, and the image_segment_adjustments / image_segments results in the same bucket. That bucket is a runtime HA .storage structure (data["maps"][<vacuum>][<map_id>]), not a filesystem JSON file β see Β§8.1.
7. Excluded History Entries¶
Retired (mapping split). The interactive bounds-review surface β which let a user exclude an outlier run's
job_bounds_historyentry from the accumulated box β is fully gone, along with thejob_bounds_historystore it operated on (Β§3).
- Frontend view deleted outright.
src/renderers/mapping-review.js,src/actions/mapping-review.js,src/state/mapping-review.js, andsrc/styles/mapping-review.jsno longer exist, and the "Map Bounds Review" nav tab/view is removed. (Some leftovermapping_review.*i18n keys still sit in the locale bundle β a separate code-cleanup, not a feature.)- Services gone.
clear_room_bounds,exclude_room_job_bounds,restore_room_job_bounds,rebuild_room_bounds_from_archive, andget_room_bounds_snapshotare all absent fromservices.yaml.- Scoring gone. The
boundary.pytransition-candidate scoring that flagged L-shaped / corridor rooms was removed;boundary.pynow holds onlypoint_in_polygon(Β§9.2).There is no longer any bounds store to exclude from. The retired review design lives in git history.
8. File Layout¶
The map bucket is a runtime HA .storage structure (data["maps"][<vacuum>][<map_id>]), not a filesystem JSON file. Rendered map PNGs live on disk under <config_dir>/eufy_vacuum/maps/<vacuum_slug>/ (Β§6.1); the passive per-vacuum diagnostics (the dock-drift log, the access graph) live under <config_dir>/eufy_vacuum/mapping/<vacuum_slug>/.
8.1 Map Bucket¶
The per-map bucket keyed at data["maps"][<vacuum>][<map_id>], ensured by maps/map_manager.py. Contains:
map_id,metadata,summaryβ identity plus discovery/rebuild bookkeeping.roomsβ per-room dict keyed byroom_idstring, holding each room's cleaning settings (name,slug,enabled,order,profile_name,floor_type,clean_mode,fan_speed, access grants, etc. β seemaps/map_manager.py). The former per-roombounds/job_bounds_historylearned from traces are gone (Β§3).image_variantsβ dict of variant metadata records.packageβ the mapping package (see below).
The same bucket also carries the CV/Custom toggle state, the named custom-layout collection, and its UI overlays (see Β§10 for the full treatment):
segmentation_modeβ"cv"or"custom". Defaults to"cv"when absent. Selects which segment storeget_map_segmentsserves; flipping it never re-runs the segmenter. Incustommode it serves the active layout.image_segmentsβ the CV baseSegmentationResultcache (engine-derived). Special at the map-bucket level β there is exactly one.custom_layoutsβ{layout_id: layout}dict of named custom layouts. Each layout is{id, name, backdrop_variant, backdrop_source, custom_segments, segment_room_links, companion_anchors, render_mode, home_art, rooms, created_at, updated_at}β its own backdrop, authored segments, room links, companion anchors, and (on a live-map layout) the furnished-render staterender_mode/home_art/rooms(see Β§10.1 and the data model).active_custom_layout_idβ id of the layout served incustommode (ornull).custom_segmentsβ legacy single user-authored store, in the sameSegmentationResultshape asimage_segments. Migrated lazily and non-destructively into a"Custom"layout undercustom_layouts(Β§10.2); kept, never deleted.segment_room_linksβ{segment_id: room_id}user-assigned 1:1 segmentβroom mapping. At the map-bucket level this is CV's link store; each custom layout owns its ownsegment_room_links.companion_anchorsβ{room_id | "dock": {pct_x, pct_y}}anchor positions (0β100% of image, from top-left) for the animated companion sprite, including the reserved"dock"mascot spot (not a room). At the map-bucket level this is CV's anchor store; each custom layout owns its owncompanion_anchors.
8.2 Mapping Package¶
The package key holds the legacy mapping-package envelope β an opaque dict accepted wholesale by the map-package import handler. Historically it carried:
room_definitionsβ dict keyed byroom_id. Each definition has labels, slugs, segment link (suggestion_segment_id), anchor pixel/vacuum, zone tags, adjacency, etc.segment_adjustmentsβ per-segmentoffset_x,offset_y, edge deltas, vertex movesdockβ pixel/vacuum anchor for the dock, exclusion radiusimagesβ variant metadata records (merged withimage_variants)
Its trace-era fields (e.g. the old trace_evidence evidence records) are no longer produced by the integration; treat this envelope as import/back-compat surface rather than a live authoring path.
Note that segment_room_links and companion_anchors are not part of this package JSON. They are runtime-bucket overlays held on the .storage map bucket (Β§8.1, Β§9.1) and enriched onto each segment at read time by _build_segments_response / _handle_get_map_segments β the canonical image_segments / per-layout custom_segments caches are never mutated by them. In cv mode the links/anchors come from the map-bucket dicts; in custom mode they come from the active layout's dicts (Β§10.2). companion_anchors may additionally hold the reserved "dock" key (the docked-home sprite spot), which is not a room.
8.3 Raw Samples Archive (per room) β retired¶
Retired (mapping split). The per-room raw-sample archive (
raw_samples_room_<room_id>*.jsonl,RAW_SAMPLES_MAX_LINES, the slug-aware_find_raw_samples_pathdiscovery) was written by the deletedRoomBoundsStore(Β§3) and no longer exists. The one live per-vacuum diagnostic file undermapping/<vacuum_slug>/is_dock_drift.jsonlβ a passive dock-coordinate drift log (MappingTracker._append_dock_drift), never fed into cleaning or bounds.
8.4 Active Samples Temp File (in-flight) β retired¶
Retired (mapping split). The in-flight sample temp file (
_samples_active.json,SAMPLES_FLUSH_INTERVAL, mid-job recovery) belonged toRoomBoundsStore(Β§3) and is gone. Run-active pose sampling for native attribution instead buffers into the run's.storageslot β seeeufy-native-transition.md.
9. Porting Considerations¶
9.1 Eufy-Specific¶
The following are specific to Eufy's implementation:
- Vacuum coordinate units β Eufy reports
robot_position_x/robot_position_yas integer sensor states in its proprietary unit. The scale (β 1 mm/unit) is inferred and not guaranteed. - Map image format β Eufy's map images use a consistent hue-per-room convention. The dark variant provides saturated, high-contrast room colours; the light variant provides near-white walls. Both properties are assumed by the HSV thresholding approach.
- HA entity model β
MappingTrackerlistens to Home Assistant state change events for position sensors and fires theeufy_vacuum_room_completedHA bus event. This is an HA-specific pattern. - HA storage β the map bucket (
rooms,image_variants,image_segments,image_segment_adjustments, custom layouts, overlays) lives in the integration's runtime HA.storagestructure, keyed atdata["maps"][<vacuum>][<map_id>]β not on the config-dir filesystem. Only rendered PNGs and the passive per-vacuum diagnostics (_dock_drift.jsonl,access_graph_<map_id>.json) are files on disk. (The formerRoomBoundsStorefilesystem map file was removed with the mapping split.)
9.2 Generic / Portable¶
The following components have no Eufy-specific dependencies and would port directly to another robot or framework:
mapping/segment_primitives.pyβ brand-agnostic geometry/image primitives (polygon math, mask ops, HSV helpers). Pure Python; only dependencies Pillow, NumPy, SciPy.rasterize_primitives(inmapping/segment_primitives.py) β the no-CV counterpart to the segmentor. Takes an ordered list of pct-space primitives (rect/circle/polygon, each optionallyop: subtract; default unions), draws them onto a PIL"1"mask, traces the boundary with the samemask_to_polygonthe CV pipeline uses, and scales the result to the map's pixel dimensions. Inputs are plain dicts with 0β100 coordinates; output is a pixel-space polygon. Dependencies: Pillow (Image/ImageDraw) + NumPy only (no SciPy). This is the portable core of the custom-segment writer (Β§10).adapters/eufy/segmentor.pyβ the Eufy HSV segmentation pipeline built on those primitives. Inputs: a PNG file path. Outputs: a pure-Python dict. A new brand provides its own segmentor module and registers it as a segmenter engine (mapping/segmenter_engines.py) β see the brand-agnostic adapter docs.boundary.pyβpoint_in_polygon(ray casting), the one geometry helper the live map layer still needs (used bymap_source.zone_membership). The rest of the module β Douglas-Peucker simplification, corner detection, transition-candidate scoring, shoelace area, convex hull β was removed with the mapping split.
10. Custom Segments, Named Layouts & the CV/Custom Toggle¶
The image segment pipeline (Β§2) is a CV pipeline: it derives room polygons from pixel data. For maps where that derivation is unreliable β or where the user would rather author rooms from primitive shapes than tune the segmenter β the system offers a parallel custom path. CV and custom segments coexist on the same map, both wearing the identical segment shape so room-linking, adjustments, and dispatch treat them uniformly.
The custom side is no longer a single store. A map now holds a named collection of custom layouts β e.g. a "solar system" image and a "tree" image, each its own backdrop, authored segments, room links, and mascot anchors. segmentation_mode still chooses CV vs custom; in custom mode the active layout is served. CV stays special at the map-bucket level (one image_segments cache plus the map-bucket segment_room_links/companion_anchors); custom layouts sit alongside CV, never as "layout 0".
10.1 CV at the bucket, custom layouts alongside¶
Each map bucket holds:
image_segmentsβ the CV base, produced by whicheverMapSegmenterengine the adapter selected (Β§2). Purely engine-derived; exactly one. Re-running CV (analyze_map_imagewithforce_reanalyze) re-segments and forces a relink.custom_layoutsβ{layout_id: layout}, a dict of named custom layouts. Each layout owns everything per-layout:id,name,created_at,updated_atbackdrop_variantβ its own backdrop image key (custom_<layout_id>, orcustomfor a migrated layout)custom_segmentsβ the user-authored set for this layout (sameSegmentationResultshape asimage_segments)segment_room_linksβ{segment_id: room_id}for this layout. Because links are per-layout, two layouts can each have a segment idlivinglinked to different rooms β impossible under the old single-store model.companion_anchorsβ{room_id | "dock": {pct_x, pct_y}}for this layout, including the reserved"dock"mascot spot.active_custom_layout_idβ the layout served in custom mode (ornull).
segmentation_mode ("cv" | "custom", default "cv") selects which segment store get_map_segments serves; in custom mode it serves the active layout. All stores persist independently β toggling the mode (or switching layouts) is a pointer change, not a regeneration. Flipping cv β custom β cv returns each segment set untouched, with zero re-analysis. _handle_get_map_segments echoes segmentation_mode, active_custom_layout_id, the custom_layouts summary, and the active segment_room_links so the card knows exactly which store it is rendering. Beyond those, the response also carries saved_zones, companion_anchors, hidden_regions (map-level physical masks), area_label_anchors (map-level device-room label positions), image_variants, and adjustments. The per-layout custom_layouts summary now includes backdrop_source alongside backdrop_variant, plus the furnished-render keys render_mode, home_art, and rooms (Wave 0 furnished custom render).
10.2 The active-scope seam and legacy migration¶
Every read/write handler resolves the live stores through a single seam, _resolve_active_scope(map_bucket), which returns {mode, layout_id, segments_store, links, anchors, backdrop_variant}:
- CV branch (
mode == "cv") β the map-bucket keys:image_segments, and the map-bucketsegment_room_links/companion_anchors(created viasetdefault, so the returned dicts are the real mutable stores). - Custom branch (
mode == "custom") β the active layout'scustom_segments/segment_room_links/companion_anchorsand itsbackdrop_variant. When no layout resolves, empty read-only stores are returned.
get_map_segments reads through the resolved segments_store/links/anchors/backdrop_variant; set_segment_room_link and set_companion_anchor mutate the resolved links/anchors (so the existing 1:1-enforcement and 0β100 clamp logic is unchanged, now per-active-store); set_custom_segments targets the active layout (auto-creating a default first β Β§10.5).
Supporting helpers:
_migrate_custom_layouts(map_bucket)β lazy + non-destructive. Returns immediately oncecustom_layoutsexists. Otherwise it creates an emptycustom_layouts(andactive_custom_layout_id); if a legacy singlecustom_segmentsstore with segments is present it folds a copy of it into a default layout named"Custom"(backdropcustom), copying only thesegment_room_linkswhose segment ids resolve against the legacy store and thecompanion_anchorsfor the"dock"key plus those linked rooms β leaving CV's map-bucket dicts intact. The legacycustom_segmentskey is kept, never deleted, so the migration is idempotent. Every handler calls this before resolving scope._active_custom_layout(map_bucket)β the active layout dict, orNone._create_layout(map_bucket, name, *, backdrop_variant=None, activate=True)β mints a layout with empty stores and acustom_<layout_id>backdrop variant (unless one is supplied), and activates it by default._ensure_default_layout(map_bucket, *, backdrop_variant="custom", name="Custom")β returns the active layout, creating + activating one when none exists (keeps authoring valid with zero layouts; backward-compat with the pre-layout flow, whose backdrop sits at the sharedcustomvariant).
10.3 Per-layout backdrop variant¶
Each custom layout needs its own backdrop image because the CV variants may not exist (or may be unsuitable) for a manually-authored map. upload_map_image (Β§6) gains an optional layout_id:
- When
layout_idis supplied, the server forces the variant tocustom_<layout_id>(ignoring anyvariantfield), validates that the layout exists ({"saved": false, "reason": "layout_not_found"}otherwise), writes the PNG, and repoints that layout'sbackdrop_variantto the new key (touching itsupdated_at). - The custom backdrops are never auto-segmented:
_handle_analyze_map_imageonly probesdark/default(primary) andlight(assist), so uploading anycustom*image does not trigger the segmenter. - The active layout's backdrop
image_width/image_heightare the rasterise canvas β the pixel spaceset_custom_segmentsscales that layout's authored polygons into.set_custom_segmentsrefuses to run ({"saved": false, "reason": "no_custom_backdrop"}) only when the active layout's backdrop variant has no known dimensions and the call sent nobackdrop_width/backdrop_heightfallback (a live-image-backed layout supplies those dims instead β see Β§10.4). - The card renders the custom backdrop with
object-fit: fill(theevcc-map-image--fillmodifier) so authored percentage coordinates map 1:1 to the displayed frame, whereas CV-mode backdrops renderobject-fit: contain.
10.4 set_custom_segments β replace-all authoring (active layout)¶
set_custom_segments(vacuum_entity_id, map_id, segments, backdrop_width?, backdrop_height?) rebuilds the active layout's custom_segments store from scratch each call (replace-all), after _ensure_default_layout guarantees an active layout exists. The segments field is a list of segment dicts; extra keys are allowed (extra=vol.ALLOW_EXTRA):
{
"segments": [
{
"id": "custom_1",
"primitives": [
{"type": "rect", "x": 10, "y": 20, "w": 30, "h": 25},
{"type": "circle", "cx": 60, "cy": 40, "r": 12},
{"type": "polygon", "points": [[5, 5], [40, 5], [22, 38]]},
{"type": "rect", "x": 15, "y": 25, "w": 8, "h": 8, "op": "subtract"}
]
}
]
}
Each primitive is {type: rect|circle|polygon, op?: subtract, ...geom} with all geometry in 0β100% of the map. id is optional; when omitted the server assigns custom_<N>. Preserving a stable id across re-saves keeps that layout's segment_room_links (Β§5/Β§10.7) attached.
Server-side, each segment's primitive list is rasterised by segment_primitives.rasterize_primitives (run in the executor, since mask_to_polygon is blocking):
- A PIL
"1"(1-bit) mask is created at a working resolution (512 px square). - Primitives are drawn in order:
op: subtractclears (fill=0), anything else unions (fill=1). Order matters β a later subtract carves out earlier fills. - The mask's outer boundary is traced by
mask_to_polygonβ the same function the CV pipeline uses (Β§2.7) β then scaled to the active layout's backdrop pixel dimensions, yielding apolygon_pixellist.
A live-image-backed layout has no uploaded backdrop, so the card sends the optional backdrop_width / backdrop_height (coerced to int via SET_CUSTOM_SEGMENTS_SCHEMA) as the rasterise canvas β the rendered live image's natural pixel size; when they are used the handler sets that layout's image variant to "live". The no_custom_backdrop refusal ({"saved": false, "reason": "no_custom_backdrop"}) is returned only when neither an uploaded backdrop variant with known dimensions nor the sent backdrop_width/backdrop_height are available.
Degenerate results (nothing drawn, or no traceable boundary) are dropped and counted in the response's skipped field. Each surviving polygon is wrapped by _build_custom_segment into the CV segment shape (source: "custom", quality: "custom", confidence: 1.0, structural_role: "room", etc.).
Modelling guidance:
- One segment = one room. A segment with a single primitive is one simple room.
- Multiple primitives in one segment = a merged room β they union into a single polygon (e.g. an L-shaped room built from two rects).
op: subtractcarves. A subtract along an edge produces a concave-but-simple polygon. An interior hole cannot be represented βmask_to_polygontraces a single outer loop, so a fully-enclosed cutout is not held by one polygon.
10.5 Layout CRUD services¶
Four services manage the named-layout collection. All are supports_response=True and route through _migrate_custom_layouts before mutating:
| Service | Effect | Returns |
|---|---|---|
create_custom_layout(name?, backdrop_source?) |
Mint + activate a new layout (empty stores, per-layout custom_<id> backdrop variant) via _create_layout, then flip segmentation_mode to custom. name defaults to "Custom". The optional backdrop_source (CREATE_CUSTOM_LAYOUT_SCHEMA, vol.Optional("backdrop_source"): cv.string) pins the backdrop: "live" composes the rooms straight over the brand's live-map image instead of an uploaded custom_<id> backdrop variant (surfaced in the get_map_segments layout summary). |
{saved, layout_id, layout} |
rename_custom_layout(layout_id, name) |
Rename an existing layout (touches updated_at). |
{saved, layout_id, layout} or {saved: false, reason: "layout_not_found"\|"missing_name"} |
delete_custom_layout(layout_id) |
Delete a layout and best-effort remove its backdrop image/variant. If the active layout is deleted, reassign active to the first remaining layout (ordered by name); if none remain, set active_custom_layout_id = null and flip segmentation_mode back to cv. |
{saved, deleted, layout_id, active_custom_layout_id, segmentation_mode} |
set_active_custom_layout(layout_id?) |
Activate a layout and flip to custom mode. A null or unknown layout_id auto-creates + activates a default layout (via _create_layout), so custom mode always resolves a live store. |
{saved, active_custom_layout_id, mode} |
The legacy single-store services keep working through the seam: set_custom_segments targets the active layout (auto-creating a default first), and set_segment_room_link / set_companion_anchor write to whichever links / anchors store _resolve_active_scope selects (CV's map-bucket dicts in cv mode, the active layout's in custom mode).
10.6 set_segmentation_mode β the toggle¶
set_segmentation_mode(vacuum_entity_id, map_id, mode) with mode β {cv, custom} writes the segmentation_mode flag.
Invariant: the handler only flips the flag. It never re-runs the segmenter, in either direction.
image_segmentsand every layout'scustom_segmentsare left exactly as they were, so the toggle is reversible with no data loss. (_handle_set_segmentation_modedocuments this invariant inline.)
When flipping to custom with no active layout but layouts present, the handler soft-selects the first layout (sorted by id) so the view always resolves a store; a hard auto-create only happens in the CRUD handlers (Β§10.5). The response returns the new mode and the segment_count of whichever store _resolve_active_scope now selects.
10.7 New per-map bucket keys¶
The custom path adds these keys to the per-map .storage bucket (alongside image_segments, image_segment_adjustments, image_variants β see Β§8.1):
| Key | Shape | Purpose |
|---|---|---|
segmentation_mode |
"cv" | "custom" (default "cv") |
Selects CV vs custom; in custom mode get_map_segments serves the active layout |
custom_layouts |
{layout_id: {id, name, backdrop_variant, custom_segments, segment_room_links, companion_anchors, created_at, updated_at}} |
The named custom-layout collection (each layout owns its own backdrop, segments, links, anchors) |
active_custom_layout_id |
str \| null |
Id of the layout served in custom mode |
custom_segments |
SegmentationResult dict (same shape as image_segments) |
Legacy single store; migrated lazily/non-destructively into a "Custom" layout, then kept untouched |
segment_room_links |
{segment_id: room_id} |
CV's 1:1 segmentβroom assignment (each layout has its own copy) |
companion_anchors |
{room_id \| "dock": {pct_x, pct_y}} |
CV's companion-sprite anchors (0β100% from top-left; reserved "dock" mascot spot; each layout has its own copy) |
polygon_pct, the enriched room_id, and applied adjustments are all derived at read time in _handle_get_map_segments, not stored on the cached segments.
10.8 Card composer (scope)¶
In custom mode the rooms-panel map toolbar exposes a shape composer (src/state/map.js, src/renderers/map.js, src/bindings/map.js). It edits an in-memory list of authored shapes and serialises them to set_custom_segments on save. Each shape is {id, type, ...geom, group?, op?, room_id?, angle?}, where group defaults to the shape's own id (so each shape is its own segment/room by default).
The composer is scoped to the active layout: setMapSegmentsData resets the draft (_composeDraft and friends) whenever the map id or active_custom_layout_id changes, and the reload guard _composeLoadedFor is keyed on ${map_id}:${active_custom_layout_id} (via _composeKey), so switching layouts reloads that layout's saved shapes and mascot anchors.
Composer operations:
| Operation | Effect |
|---|---|
| add rect / circle | Append a new shape (default angle: 0) |
| select | Pick a shape (or its merged group) for the next operation |
| move | Translate; Room (whole group, the default) or Piece (single member) scope when merged |
| tap-to-place | Drop the selected shape at a tapped point |
| scale | Resize about the shape centre |
| resize (W/H) | Adjust width/height β rect only |
| rotate (Β±15Β°) | Rects carry an angle applied at render and baked to a polygon on save; polygons rotate their points directly; circles are a no-op |
| merge | Two-tap two shapes into a shared group β ONE segment (group-coloured) |
| cut | Mark a grouped shape op: subtract so it carves the room (rendered dashed/red) |
| split | Drop a member out of its group back to its own segment |
| link-to-room | Assign a managed room to a whole merged group (enforced 1:1) |
| save | Replace-all set_custom_segments, then reconcile per-segment room links |
| re-edit | Reload saved polygons back into the composer once per map |
On save, composeToSegments buckets shapes by group (default = each shape's own id), orders op: subtract members last within each group (so cuts apply after fills, matching the server's in-order rasterise), and carries the per-segment room_id (whichever group-mate holds it, since the link is 1:1 per group).
10.8.1 Layout picker¶
The old binary CV/Custom toggle is now a picker rendered by _renderSegmentationToggle (src/renderers/map.js):
- An "Auto (CV)" chip (
data-action="set-segmentation-mode",data-mode="cv"), always present. - One chip per named layout (
data-action="set-active-custom-layout",data-layout-id=...), the active one highlighted; switching a chip swaps the whole layout (backdrop + authored rooms + links + mascot home). - A "οΌ New" chip (
data-action="open-new-layout") that opens the inline name editor. - When a layout is active, Rename / Delete layout buttons plus an inline name-editor
<input>(a single editor slice shared by create and rename).
The picker reads card state added in src/state/map.js β customLayouts(), activeCustomLayoutId(), activeCustomLayout(), and the layout-editor slice (isLayoutEditorOpen / layoutEditorMode / layoutDraftName / setLayoutDraftName / openNewLayoutEditor / openRenameLayoutEditor / closeLayoutEditor). The bindings (src/bindings/map.js) call the matching actions in src/actions/map.js β createCustomLayout / renameCustomLayout / deleteCustomLayout / setActiveCustomLayout β and a custom backdrop upload passes layout_id (the active layout id) to uploadMapImage so the server forces the custom_<layout_id> variant.
10.8.2 Companion dock spot and toolbar toggles¶
Two adjacent toolbar concerns share the same map view:
- Mascot dock spot. When the vacuum is
dockedoridle, the companion sprite homes to the reserved"dock"key incompanion_anchorsβ a single map-level spot, not a room. Dragging the mascot while docked writes that"dock"anchor (the same drag that, mid-clean, writes a per-room anchor). Until the dock spot is set, the mascot falls back to the resolved dock segment's centroid. - Mascot on/off. Card state
mapAnimalEnabled(localStorageevcc_animal_on_<vac>, default on) is separate from animal selection. A paw button in the rooms-panel map toolbar toggles it;_renderMapAnimalreturns''when off. - Floor-texture on/off (split). Two independent card-state toggles:
mapFloorTextureEnabled(localStorageevcc_floor_tex_map_<vac>) gates the map texture polygons (_renderFloorTexturePolygon/_buildFloorTextureDefs);roomFloorTextureEnabled(evcc_floor_tex_rooms_<vac>) gates the room-card texture layers (_renderFloorTextureLayer). Both default on and seed from the legacy unifiedevcc_floor_tex_<vac>key on first read. Two hatch buttons toggle them (map-texture-toggle/room-texture-toggle); the map one is map-view-only, the room-card one stays in the toggle row for list view.
11. Provider Map Source (map_state_source)¶
The local subsystems above derive room data differently β image analysis reads room shape from map pixels (Β§2), and the tracker reads which room the robot is in from the device's native current-room signal (Β§1; the trace-bounds learning it replaced is retired, Β§3). The provider map source is a third reader that instead normalises the room layout the device's own firmware already knows. Where image analysis is a best-effort pixel inference, this reads the provider's authoritative segmentation directly, so room tap-regions, current-room, and anchors are auto-derived rather than hand-composed.
This section is an orientation only; the full design β wave scope, both brand backends, the normalisation transform, and overlay-layer details β lives in the authoritative design reference dev/map-state-source.md.
11.1 Modules¶
| Module | Role |
|---|---|
mapping/map_source.py |
Brand-agnostic, HA-free pure core. Turns the device's segmentation (raster + anchors) into normalised room data (per-room bbox + name + dock/robot anchors), in 0β1 of the rendered image (top-left origin, Y-flip applied) β the same space the card draws zones/labels in. Unit-testable without Home Assistant. |
mapping/map_source_runtime.py |
The HA-aware runtime locators that find the provider's data and hand plain dicts to the pure core. Eufy uses the storage backend (reads eufy-clean's .storage/robovac_mqtt.<serial> Store); Roborock uses the memory backend (a defensive introspector over the in-memory parsed MapData on the map image entity). Both apply the live-map presence gate and degrade to an absent marker β never raise. |
mapping/map_source_coordinator.py |
MapSourceCoordinator β the async backend dispatcher (bundled-subsystem pattern, constructed with the core manager; extracted from core/manager.py, which keeps one-line delegators). |
11.2 Coordinator surface¶
MapSourceCoordinator exposes four public async readers, dispatched by the adapter's declared map_state_source backend/format:
async_refresh_map_state_source(...)β pre-warm dispatcher; reads the adapter'smap_state_sourceblock, applies the presence gate, and writes the normalised result intomanager._map_state_source_cacheso the sync on-loop dashboard snapshot can include it without doing the blocking.storageread itself.async_get_map_live_pose(...)β lightweight live-pose poll.async_compare_map_sources(...)β in-memory-vs-storage verify probe.async_get_map_render_data(...)β the card's own-render raster fetch.
Two seams deliberately stay on the manager: _map_state_source_cache (the pre-warm writes it; the on-loop snapshot composer and the map-overlays sensor read it directly) and _resolve_live_map_image_entity (shared with the dashboard snapshot composer as the live-map presence gate).
11.3 Relationship to Β§1/Β§2¶
This reader does not replace image analysis or the native-current-room tracker β image analysis remains the path for maps where no provider segmentation is available, the tracker owns run-time room presence (Β§1), and the local subsystems own the CV/custom authoring machinery. The provider map source is consumed by the live-map overlay path: because it normalises into the rendered-image (0β1) space the card already draws in, its bboxes/anchors overlay the device-rendered backdrop directly rather than needing the (removed) pixelβvacuum transform (Β§4).