Skip to content

07 — Mapping — Subsystem Test Map

The mapping subsystem turns map images into room data: an image-segmentation stack (segment_primitives, segmenter_engines), a room tracker (tracker) that fires room_completed off the device's native current-room, and the service orchestrator (mapping_services). A second authoring path lets a human draw rooms directly: segment_primitives rasterises composer shapes into polygons, and mapping_services holds many named custom layouts per map alongside the CV store, selected by a segmentation_mode pointer flip.

Covered by 392 tests across 16 files (regenerated by scripts/update_test_docs.py). A third path skips authoring entirely: the map_state_source reader (map_source, map_source_runtime, map_source_coordinator) normalizes the provider's OWN segmentation + live pose into VA-owned room data (bbox/name, dock/robot anchors, area, current room, overlay layers), so rooms are auto-derived from the device's authoritative map rather than learned from drifting samples or hand-drawn. The image primitives are near-fully covered; the tracker and the service orchestrator have both their pure helpers (unit) and hass-bound bodies (integration) covered; and the real detect_room_segments CV pipeline runs end to end against a synthetic image.

Mapping split. The run-derived inference lineage — the trace_capturetrace_storetrace_segmentationtrace_review pipeline, room-boundary derivation, and image-segment suggestion — was retired, and MappingManager and the learned bounding-box store room_bounds.py (RoomBoundsStore) were removed; room tracking now reads the device's native current-room. The module table below is re-stamped by scripts/update_test_docs.py and is current; the hand-written Known gaps breakdown further down is not re-stamped by that tool and has fallen behind — see the note there.

Source: custom_components/eufy_vacuum/mapping/ Architecture reference: 11 — A Map’s Stored State through 15 — The Stall Capture Image


Coverage map

Source module Stmts Cov Test file Layer Mocking
boundary.py 15 90% via zone_membership in tests/integration/test_mapping_services.py (only point_in_polygon survives) integration (pure geometry) clean
segment_primitives.py 277 93% tests/unit/test_mapping_segment_primitives.py unit (pure + numpy/scipy) clean
segmenter_engines.py 132 100% tests/unit/test_mapping_segmenter_engines.py unit (pure) clean
tracker.py 247 84% test_mapping_tracker.py + test_mapping_tracker_events.py unit + integration bare x2 in 2 files
mapping_services.py 1319 88% test_mapping_services_helpers.py + test_mapping_services.py + test_mapping_services_handlers.py unit + integration clean
map_source.py 434 93% tests/unit/test_map_source.py unit (pure) clean
map_source_runtime.py 605 90% tests/unit/test_map_source_runtime.py + tests/unit/test_map_source_collectors.py unit (pure) clean
map_source_coordinator.py 302 90% test_manager_compare_sources.py + test_manager_live_pose.py + test_manager_map_source_refresh.py + tests/unit/test_live_pose_backends.py integration + unit clean
roborock_raw_map.py 145 89% tests/unit/test_roborock_raw_map.py unit (pure) clean
stall_capture_render.py 191 93% tests/unit/test_stall_capture_render.py unit (pure) clean

What's tested

The image stack

  • segment_primitives (SP) — pure geometry (rdp, polygon_area, compactness, aspect_ratio, …), the numpy/scipy mask primitives (mask_to_polygon, mask_iou, transforms, mask_edge_band, estimate_alignment, normalized_color_features), and rasterize_primitives — the composer rasteriser that turns rect/circle/polygon shapes into a mask (fill + ordered subtract ops) for custom segments.
  • segmenter_engines (SE) — the engine registry, tuning validation, and the no-image/noop unavailable paths. The CV pipeline body (detect_room_segments) is exercised only through its failure paths.

The stall capture renderer

  • stall_capture_render (SC) — the one-room capture drawn for a stall notification: a flat silhouette from the room-id raster, the pose trail, a position dot, and a black-on-white name pill. Pure — bytes in, PNG bytes out, no hass and no adapter — which is what lets the maintainer dev card fire it repeatedly and what keeps it testable without a fixture.

Every interesting target here is an ABSENCE case, because this sits on a job-lifecycle path and consumes values that are legitimately missing. A docked robot has no anchor (the pose sampler nulls it by design), a held map source nulls it too, an unsegmented room has no pixels, and Pillow is an optional dependency the install matrix expects absent. SC-3/SC-4/SC-5/SC-6 pin that each degrades to a smaller picture or to None — never an exception, and never a fabricated coordinate. SC-4 compares renders byte-for-byte with and without an anchor, so a None silently coerced to (0, 0) fails rather than drawing a dot in the corner.

SC-2 is the one to read for the RASTER half. It calls _to_rendered directly and pins that room cells are offset by ro_dx/ro_dy and, when flip_y is set, mirrored about canvas_height (raw row 0 is the image bottom) — flip_y describes the RASTER only.

⚠ This paragraph used to end "the anchor and trail arrive already normalized in the rendered frame, so they must NOT be flipped" and sold that as something SC-2 pins. The RULE is true — render_room_capture puts anchor and trail through _norm_to_px only, never _to_rendered, because they arrive already normalized 0–1 against the canvas — but SC-2 does not pin it: SC-2 never calls render_room_capture at all, and no test in tests/unit/test_stall_capture_render.py passes ro_dx, ro_dy or flip_y into it. The two sides of that seam are exercised separately and never through it. Getting the rule wrong renders a plausible image of the wrong part of the map, which is the failure most likely to pass a glance — and today nothing but review catches it.

SC-10 is an evidence rule, not a threshold. Below four DISTINCT points no trail is drawn at all. A line between two samples asserts the robot travelled straight between them; Eufy declares interval_s 2.0 so a ±30 s window is a real trace, but Roborock's pose comes from the map backdrop at ~30 s, so the same window yields two or three. Joining those would fabricate a route in the one artifact whose job is to show what actually happened. Identical consecutive anchors also collapse to one point — a wedged robot repeats its pose, and it must not read as movement.

SC-11 rotates to the user's map orientation, negating the angle because the card rotates with CSS transform: rotate(Ndeg) (clockwise) and PIL rotates counter-clockwise. Applied after the vector layer and before the label, so the room turns and the text stays level. Pinned with a 40×10 room becoming 10×40, so a wrong angle cannot pass on symmetry.

The tracker

  • tracker (MT, unit) — the pure _RoomConfidenceState machine (reset_room, movement increment / saturate, reset_job).
  • tracker (MTE, integration) — the job lifecycle: register/unregister, start_job/end_job (seed / reset the confidence state), pause/resume sampling, _handle_position_update (per-tick confidence advance when a job is active, else the passive dock-drift JSONL log), the native current-room resolution (_detect_current_room / _get_raw_position), and the confidence-threshold room-exit firing eufy_vacuum_room_completed.

The service orchestrator

  • mapping_services (MS, unit) — _apply_segment_adjustments, _build_segments_response, and the module-local geometry helpers. MSH, integration — the service handlers via async_register_mapping_services: get_map_segments, adjust_map_segment, set_segment_room_link (set/clear/1:1), set_companion_anchor, delete_map_image.

Custom & multi-layout segmentation

The human-authored alternative to CV, exercised end to end through the services.

  • Authoring (test_mapping_services.py) — set_custom_segments rasterises composer primitives into room polygons (replace-all; refuses with no_custom_backdrop until a backdrop image exists), and a custom room link survives a re-save. set_segmentation_mode is a pure pointer flip that never re-runs the segmenter and losslessly switches the served store (cv ↔ custom).
  • Named layouts (LAYOUT-*, test_mapping_services.py) — the custom_layouts collection lifecycle: a legacy single custom_segments store migrates into one default layout (custom-resolved links/anchors move onto the layout, CV's stay on the map bucket); create / rename / set-active / delete (create flips to custom and activates, delete-active reassigns, delete-last flips back to CV); and set-active with zero layouts auto-creates one.
  • Per-layout isolation — the same segment id may link to different rooms on two layouts (test_per_layout_segment_isolation), and companion anchors including the reserved dock spot are per-layout (LAYOUT-6) — neither bleeds across.
  • Furnished render (FURN-*, test_mapping_furnished_render.py) — the per-layout furnished-art contract end to end: set_furnished_art_placement (home/room scope, 4dp round-trip, scale clamp to [0.05, 20], clear-on-all-null, missing-room-id guard), set_furnished_render_mode (layout vs per-room, blank room_id → layout level), set_room_viewport, the upload_map_image art_scope variant routing (custom_<id>_home_art / _room_<rid> onto home_art/rooms, never the backdrop), the resolve_furnished_render projection, the delete_custom_layout art sweep, and per-layout isolation — all through the real service registry.

The map_state_source reader

The brand-agnostic read of the provider's own segmentation + live pose into VA-owned room data (architecture: docs/dev/map-state-source.md). The pure extraction/normalization is unit-tested without Home Assistant; the manager-facing seams (delegators into MapSourceCoordinator) are integration-tested.

  • map_source (MS-*, unit, test_map_source.py) — the pure core: rooms_from_room_pixels (per-room bbox+name, Y-flip, catch-all rid 32 filtered, malformed/short buffers degrade to []), normalize_rendered clamp+flip, anchors_from_storage (dock/robot normalize, non-numeric coords skipped), per-room area (pixel_count × (res_cm/100)²), and build_map_source_result's presence gate (absent-with-reason vs populated, with extra overlay layers merged in). (rooms_from_parsed_map, a dead second Roborock-parser room extractor with a disputed coordinate frame, was removed -- #11:A3-EXT-5.)
  • map_source_runtime (MSR-* + MSC-*, unit, test_map_source_runtime.py + test_map_source_collectors.py) — the HA-aware glue tested with injected plain data: eufy_result_from_store (the #136 version guard, presence gate, extraction, degradation), the Roborock find_mapdata / find_roomlike_collection defensive introspector (duck-typing, cycle-safety, attr denylist), and the candidate collectors (eufy_inmem_candidates, roborock_candidates, image_entity_object) that gather roots from hass.data[domain] / per-entry runtime_data / the image entity, each degrading cleanly when a source is absent.
  • the live-pose backend seam (LP-*, unit, test_live_pose_backends.py) — async_get_map_live_pose's brand dispatch, plus robot_pose_from_mapdata / mapdata_live_pose_from_candidates in MSR-2k. The bug they pin is a DECLARATION gap, not a geometry one: the accessor was the eufy-clean reader wearing a generic name, so it keyed off a live_pose block merely existing and answered not_configured for a brand that could not describe itself in fork terms. Roborock's position was live on its parsed MapData the whole time — the card drew it while the stall capture drew rooms with no dot and the pose ring banked rows with no anchors. Covered: the declared backend selects the reader; no backend is a default (an undeclared one says so rather than falling through to a brand); the pixel-override path is gated on the pixel backend so a parsed-map brand can never drive the fork's attr walk; and the card's overlay payload and the pose accessor are asserted to read the SAME extractor, so the two cannot silently disagree about whether a map has a robot on it again.

Note the LP-3 test records the call rather than raising from the stub. _apply_inmem_pose_to_result swallows Exception by design, so a raising probe is eaten and the test passes with the gate removed — it did, until ablation caught it. - the native current-room HIGHLIGHT source (CRR-*, unit, tests/unit/test_current_room_resolve.py; MSD-14/MSD-15, integration, test_manager_map_source_refresh.py) — the SAME sibling-drift as the live-pose seam, one layer over. The render frame's current_room (what the card's current-room layer fills) is supplied per brand: Eufy by pixel lookup, Roborock by vacuum_room. Dreame's decoded map leaves vacuum_room null, so the camera_attrs render carried no current_room and the highlight stayed dark — even though the brand answers the question directly via its native active_cleaning_target NAME entity (already consumed for attribution). rooms/current_room.py::resolve_native_current_room_id is the ONE resolver both consumers share (name → slug → managed room id; None for the dock / a transit room / an unmatched name); a THIRD, deliberately different variant on ActiveJobTracker matches the job QUEUE and is not unified. MSD-14 is the declaration-proving gate: a native_current_room brand whose decode omits current_room gets it injected from the NAME entity, and it goes red (KeyError: current_room) with the injection removed — ablation-confirmed. MSD-15 holds the None case (an unmatched name draws nothing rather than a wrong room). - roborock_raw_map (RRD-*, unit, test_roborock_raw_map.py) — the pure Roborock v1 raw-map segment decoder, no HA/device: decode_roborock_v1_segments (a well-formed IMAGE block → resolved room-id raster + dims + ids; no-IMAGE / empty / truncated / garbage / dims-exceed-data → None, never raises), resolve_rid's per-pixel encoding (byte >> 3 only when the low 3 bits == 7; 0xFF catch-all; scan/wall/outside → 0; the 0x08 obstacle-bit collision a naive shift would mislabel as room 1), roborock_render_data wrapping a decoded raster in the shared room_pixels_v1 payload (b64 round-trip, flip_y, catch_all_rid, room_names), the ..._from_candidates introspection bridges (BFS to a MapContent, absent-marker on no match), raster_room_bboxes (per-room normalized bbox with flip_y honored so raw top rows land at the rendered bottom — the Ivy finding), and geometry_drift, the on-device decode self-validator overlaying the parser's bboxes against the raster (aligned / IoU / centre-drift, flip detection, only_parser set-diff). - dreame_render_from_mapdata (DMD-*, unit, test_dreame_mapdata_decode.py) — the pure Dreame raster decoder over the base integration's decoded MapData (no HA/device; a SimpleNamespace fake shaped from the live ground-truth probe on vacuum.robin): TRUE per-room area_m2 from the pixel_type grid (a segment's pixels are value ∈ {id, 100+id, 200+id} × grid_size² — DMD-2 bites the border 100+id term, DMD-3 the grid_size term), bbox projection via the base's own dimensions transform (normalized 0..1), user-placed furniture as a neutral-slug overlay (+ room + size), robot/dock anchors, and the no-dimensions → absent-marker degrade. dreame_render_data_from_mapdata builds the shared room_pixels_v1 raster (pixel_type → resolved rids {id,100+id,200+id}, transposed to row-major ry*width+rx, flip_y auto-detected by matching a room's raster centroid to its projected centroid) — DMD-6/7/8 pin the format contract, rid mapping, and row-major order on a non-square fake so [x][y] vs [y][x] can't hide. The device-side readers (dreame_coordinator via the public coordinator.device, dreame_mapdata_candidates) are validated live, not unit-tested. - map_source_coordinator (CMP-* + LP-*, integration, test_manager_compare_sources.py + test_manager_live_pose.py) — the manager delegators into MapSourceCoordinator: async_compare_map_sources (the verify probe — not_configured / memory_not_configured guards, flags-only when only one source is present, compare_map_data reached when both are, diagnostics breadcrumb passthrough) and the live-pose seam async_get_map_live_pose / _apply_inmem_pose_to_result (robot/dock/heading/trail overlaid on present pose, base overlays preserved when pose is absent or its read raises, no_geom reason when geometry is missing). - map_source_coordinator dispatcher + backends (MSD-* / GLM-* / RND-* / RIP-* / LPG-*, integration, test_manager_map_source_refresh.py) — the pre-warm async_refresh_map_state_source routing (storage / memory-primary / memory / unknown-backend / error-degrade, plus the live-image presence gate), the storage backend's mtime-cache hit + version-mismatch warn + no-device marker, the memory-primary scan with its content-version cache and the three .storage fallbacks (memory miss / convert-None / present-False), get_live_mapdata_obj (the zone-dispatch object locator across both backends, None on absence/raise), async_get_map_render_data (memory-primary vs .storage vs unknown-format, plus the Roborock roborock_raw_map_v1 format dispatching to the raw-map render bridge with room names from the manager's stored rooms), and the live-pose read layer (_read_inmem_pose, _load_live_pose_geom read + mtime cache + no-map-data). The _msr parsers stay unit-tested; these pin the coordinator's own dispatch / cache / fallback branching, each fallback asserted via an assert-not-called guard so the intended branch is proven, not just non-crashing.


How it's tested

Four patterns, same as elsewhere in the suite:

  1. Pure import (Recipe C) — the geometry, engine registry, and the service helpers. No fixtures.
  2. tmp_path filesystem — the tracker file helpers (the dock-drift JSONL) get an isolated config dir. The hass is a MagicMock with config.config_dir = str(tmp_path):
    hass = MagicMock(); hass.config.config_dir = str(tmp_path)
    tracker = MappingTracker(hass)
    
  3. numpy/scipy arrays — the segment_primitives mask tests build real arrays. numpy ships transitively via HA core; scipy and Pillow are explicit test deps (see requirements_test.txt). Scipy-only paths still guard with pytest.importorskip("scipy.ndimage") so the file degrades gracefully if the stack is ever absent.

Image/CV pipeline (IMG, integration — brand-agnostic)

The framework's segment plumbing (analyze_map_image, get_map_segments) is tested against a registered fake segmenter engine that returns a canned SegmentationResultnot a concrete brand engine. This proves the framework drives any adapter's CV pipeline without coupling to Eufy's. The real Eufy CV segmentor (detect_room_segments, HSV masks, EufyCVSegmenter) is tested solo in tests/adapters/eufy/test_segmentor.py (prefix ECV), where brand code belongs. Framework-level tests stay engine-agnostic; brand CV tests live with the brand code.

Non-segment service handlers (MSV, integration)

The surviving read/display handlers — delete_map_image, set_live_map_rotation, set_map_overlay_visibility, and get_map_render_data — exercised through async_register_mapping_services. (The room-bounds-snapshot, boundary-trace, dock, trace-capture, mapping-package, and trace-review handlers were retired with the mapping split.)

Known gaps

The module table above is current (regenerated against this revision by scripts/update_test_docs.py); the itemized breakdown below is nottracker.py (84%, down from a described 93%) and mapping_services.py (88%, down from a described 91%) have grown real new coverage gaps this campaign that are not yet itemized here (134 missed lines in mapping_services.py alone; run --cov-report=term-missing on both modules for the current line list before treating either bullet below as exhaustive). The other three modules' bullets below still track close to what they described:

  • mapping_services.py (88%) — the _handle_analyze_map_image tuning-override / light-assist wiring (the tuning dict + assist_image_path block inside that handler) was deferred: a robust test fights the filesystem-probing image store plus phac's shared config dir — not worth a fragile test. Beyond that previously-described defensive tail (the OSError delete branch in _handle_delete_map_image, non-dict guards, the tracker-absent else, the schema-unreachable coerce guards in _build_segments_response), the module now carries substantially more uncovered surface than before — not yet re-triaged.
  • tracker.py (84%) — previously-described gaps: # pragma: no cover capabilities-read / dock-drift JSONL I/O except-blocks, malformed-line resilience, and trivial early-return guards; the transition-room skip in _detect_current_room is a redundant defensive short-circuit (its normal path is covered by test_room_completed_event), deliberately left. The module has grown a further ~9 points of gap this campaign not yet itemized.
  • segment_primitives.py (93%) — empty-mask divide-by-zero returns, optional-dependency guards, and unreachable malformed-edge artifacts.
  • map_source_coordinator.py (90%) — _stat_mtime's real os.stat (monkeypatched in the dispatch tests), the except OSError stat-failure guard, the memory-backend diagnostics-log branch, and a few -> partials in the candidate loop / render / geom-cache edges. Defensive guards + real-IO; the dispatch, cache, and fallback behaviour is fully covered.

Extending

  1. A new geometry/engine behavior? Pure unit test — add a target to the matching file. Use tmp_path if it touches disk.
  2. A scipy/numpy image path? Build real arrays; gate scipy-only code with pytest.importorskip.
  3. A service handler? That's the integration pass — register via async_register_mapping_services with a real hass + tmp_path config dir.
  4. Re-measure across all mapping test files together for the true per-module number.