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_capture→trace_store→trace_segmentation→trace_reviewpipeline, room-boundary derivation, and image-segment suggestion — was retired, andMappingManagerand the learned bounding-box storeroom_bounds.py(RoomBoundsStore) were removed; room tracking now reads the device's native current-room. The module table below is re-stamped byscripts/update_test_docs.pyand 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), andrasterize_primitives— the composer rasteriser that turns rect/circle/polygon shapes into a mask (fill + orderedsubtractops) 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_RoomConfidenceStatemachine (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 firingeufy_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 viaasync_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_segmentsrasterises composer primitives into room polygons (replace-all; refuses withno_custom_backdropuntil a backdrop image exists), and a custom room link survives a re-save.set_segmentation_modeis 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) — thecustom_layoutscollection lifecycle: a legacy singlecustom_segmentsstore 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 reserveddockspot 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, theupload_map_imageart_scopevariant routing (custom_<id>_home_art/_room_<rid>ontohome_art/rooms, never the backdrop), theresolve_furnished_renderprojection, thedelete_custom_layoutart 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_renderedclamp+flip,anchors_from_storage(dock/robot normalize, non-numeric coords skipped), per-room area (pixel_count × (res_cm/100)²), andbuild_map_source_result's presence gate (absent-with-reason vs populated, withextraoverlay 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#136version guard, presence gate, extraction, degradation), the Roborockfind_mapdata/find_roomlike_collectiondefensive introspector (duck-typing, cycle-safety, attr denylist), and the candidate collectors (eufy_inmem_candidates,roborock_candidates,image_entity_object) that gather roots fromhass.data[domain]/ per-entryruntime_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, plusrobot_pose_from_mapdata/mapdata_live_pose_from_candidatesinMSR-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 alive_poseblock merely existing and answerednot_configuredfor 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_resultswallowsExceptionby 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'scurrent_room(what the card's current-room layer fills) is supplied per brand: Eufy by pixel lookup, Roborock byvacuum_room. Dreame's decoded map leavesvacuum_roomnull, so thecamera_attrsrender carried nocurrent_roomand the highlight stayed dark — even though the brand answers the question directly via its nativeactive_cleaning_targetNAME entity (already consumed for attribution).rooms/current_room.py::resolve_native_current_room_idis 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 onActiveJobTrackermatches the job QUEUE and is not unified.MSD-14is the declaration-proving gate: anative_current_roombrand whose decode omitscurrent_roomgets it injected from the NAME entity, and it goes red (KeyError: current_room) with the injection removed — ablation-confirmed.MSD-15holds 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 >> 3only when the low 3 bits == 7;0xFFcatch-all; scan/wall/outside → 0; the0x08obstacle-bit collision a naive shift would mislabel as room 1),roborock_render_datawrapping a decoded raster in the sharedroom_pixels_v1payload (b64 round-trip,flip_y,catch_all_rid, room_names), the..._from_candidatesintrospection bridges (BFS to aMapContent, absent-marker on no match),raster_room_bboxes(per-room normalized bbox withflip_yhonored so raw top rows land at the rendered bottom — the Ivy finding), andgeometry_drift, the on-device decode self-validator overlaying the parser's bboxes against the raster (aligned / IoU / centre-drift, flip detection,only_parserset-diff). -dreame_render_from_mapdata(DMD-*, unit,test_dreame_mapdata_decode.py) — the pure Dreame raster decoder over the base integration's decodedMapData(no HA/device; aSimpleNamespacefake shaped from the live ground-truth probe onvacuum.robin): TRUE per-roomarea_m2from thepixel_typegrid (a segment's pixels arevalue ∈ {id, 100+id, 200+id}×grid_size²— DMD-2 bites the border100+idterm, DMD-3 thegrid_sizeterm), bbox projection via the base's owndimensionstransform (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_mapdatabuilds the sharedroom_pixels_v1raster (pixel_type → resolved rids{id,100+id,200+id}, transposed to row-majorry*width+rx,flip_yauto-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_coordinatorvia the publiccoordinator.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 intoMapSourceCoordinator:async_compare_map_sources(the verify probe — not_configured / memory_not_configured guards, flags-only when only one source is present,compare_map_datareached when both are, diagnostics breadcrumb passthrough) and the live-pose seamasync_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_geomreason when geometry is missing). -map_source_coordinatordispatcher + backends (MSD-*/GLM-*/RND-*/RIP-*/LPG-*, integration,test_manager_map_source_refresh.py) — the pre-warmasync_refresh_map_state_sourcerouting (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.storagefallbacks (memory miss / convert-None / present-False),get_live_mapdata_obj(the zone-dispatch object locator across both backends,Noneon absence/raise),async_get_map_render_data(memory-primary vs.storagevs unknown-format, plus the Roborockroborock_raw_map_v1format 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_geomread + mtime cache + no-map-data). The_msrparsers 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:
- Pure import (Recipe C) — the geometry, engine registry, and the service helpers. No fixtures.
tmp_pathfilesystem — thetrackerfile helpers (the dock-drift JSONL) get an isolated config dir. The hass is aMagicMockwithconfig.config_dir = str(tmp_path):- numpy/scipy arrays — the
segment_primitivesmask tests build real arrays. numpy ships transitively via HA core; scipy and Pillow are explicit test deps (seerequirements_test.txt). Scipy-only paths still guard withpytest.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 SegmentationResult
— not 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 not —
tracker.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_imagetuning-override / light-assist wiring (thetuningdict +assist_image_pathblock 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 (theOSErrordelete 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 covercapabilities-read / dock-drift JSONL I/O except-blocks, malformed-line resilience, and trivial early-return guards; the transition-room skip in_detect_current_roomis a redundant defensive short-circuit (its normal path is covered bytest_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 realos.stat(monkeypatched in the dispatch tests), theexcept OSErrorstat-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¶
- A new geometry/engine behavior? Pure unit test — add a target to
the matching file. Use
tmp_pathif it touches disk. - A scipy/numpy image path? Build real arrays; gate scipy-only code with
pytest.importorskip. - A service handler? That's the integration pass — register via
async_register_mapping_serviceswith a real hass +tmp_pathconfig dir. - Re-measure across all mapping test files together for the true per-module number.