Skip to content

Map State Source β€” Auto-Derived Room Data

Status: design approved 2026-06-18. Wave 1 COMPLETE + LIVE-VERIFIED on both brands (2026-06-19). Eufy (storage backend) returns 10 rooms + dock/robot anchors, presence-gated on the live-map camera. Roborock (memory backend) returns 10 rooms with normalized bboxes, read from the parsed MapData on the map image entity (_home_trait._map_content.map_data) and projected via the parser's own ImageDimensions.to_img(...).rotated(...) transform (the live transform was verified exact by hand against the raw room coords). No consumer wiring yet (Wave 3). No eufy-clean changes required.

Current state (2026-08-06). This file is the design + build log; the shipped coordinator is documented in 12 β€” Where the Map Comes From. Wave 3 consumer wiring has since landed: the card overlay layers + "Map Layers" panel and the sensor.<vac>_map_overlays mirror (both below), and the live room signal now drives room attribution on both brands (shipped 1.8.0 β€” see eufy-native-transition, "Shipped") β€” Eufy via this reader's decoded-map pose (async_get_map_live_pose, room_attribution.source: live_pose), Roborock via its native current-room NAME entity (source: native_current_room β€” its attribution path does not go through map_state_source at all). One Wave-1 claim has since diverged: Roborock no longer omits map_render β€” see the marked note in the self-render section.

Roborock specifics (learned at runtime, 2026-06-19)

  • Parsed geometry is NOT on the coordinator (runtime_data carries only idβ†’name metadata) β€” it lives on the map image entity: image.<id> β†’ _home_trait._map_content.map_data (the newer python-roborock-traits build; older stable HA discards it and would need a cloud_api.get_map_v1() re-fetch+parse β€” not required here).
  • map_data.rooms is dict[int, Room]; Room.x0..y1 are vacuum coords (~50Γ— the pixel grid), so they MUST be projected via image.dimensions.to_img(point).rotated(dims) and normalized by image.data.size (the rendered size = dims.width/height Γ— scale). Reader calls that transform (via a lightweight _XY point shim) rather than reimplementing it.
  • The reader targets an object with BOTH .rooms and .image (a MapData) β€” never a generic x0/y0/x1/y1 collection, because no_go_areas/walls/zones are rectangles too.
  • Room.name/pos_x/pos_y are None for Roborock β†’ names fall back to Room {number}; the number is the segment id, reconciled to real names in Wave 3.

Implementation map (Wave 1)

Piece Where
Pure reader core (extraction/normalization, HA-free) mapping/map_source.py (13 tests)
Runtime locators (Eufy .storage read; Roborock introspector) mapping/map_source_runtime.py (13 tests)
Async pre-warm + cache + snapshot field mapping/map_source_coordinator.py MapSourceCoordinator.async_refresh_map_state_source / _refresh_storage_map_source / _refresh_eufy_map_source (constructed as self.map_source = MapSourceCoordinator(manager=self) in core/manager.py async_initialize); core/manager.py keeps thin 1-line delegators plus _map_state_source_cache and _resolve_live_map_image_entity; snapshot key map_state_source
Pre-warm call site (off-loop IO before the on-loop sync snapshot) services/snapshots.py _handle_get_dashboard_snapshot
Adapter config blocks adapters/eufy/adapter.py + adapters/roborock/adapter.py map_state_source

The Eufy .storage read is BLOCKING (200 KB + base64), so it runs in an executor inside the async pre-warm and is cached by file-mtime β€” the on-loop sync snapshot only reads the cache. The Roborock introspect is in-memory (loop-safe). Both degrade to {present: false, reason} and never raise.

Goal

Read the provider's own map segmentation (the device's authoritative room data β€” not bounds learned from drifting robot samples) to auto-derive, brand-general:

  • room select-regions (the tap-targets you currently hand-compose) β€” the headline win,
  • room bounds + exact current-room membership,
  • dock + robot anchors (for mascots),
  • zone mΒ².

This removes the manual room-compose step. All decode/coupling cost lives on the dev side; the user gets it for free. The drift investigation (see reference_eufy_intersession_coord_drift) established that the stable, authoritative segmentation already exists in the provider β€” we just have to read it instead of inferring it.

The seam

One VA-owned reader, driven by a per-adapter map_state_source pointer, publishes normalized map data into the dashboard snapshot. Consumers (select-box auto-gen, current-room, mascot, zone mΒ²) read the VA's output β€” never the provider directly. All provider coupling is isolated to one adapter-configured reader.

Brand reality (why a pointer, not a hardcode)

Eufy (eufy-clean) Roborock (HA core)
Source .storage/robovac_mqtt.<id> (decoded on disk) in-memory coordinator MapData
Per-room bbox from room_pixels extents parser Room.x0..y1 (direct)
Exact outline room_pixels raster (free, L-exact) reconstruct (color-seg render / re-parse segment layer)
Offline-readable yes (proven) no (geometry is in HA memory)

eufy-clean is unusually generous (persists decoded room_pixels); Roborock keeps geometry in-memory. Hence a per-adapter pointer that supports storage and memory backends.

Adapter config contract (map_state_source)

map_state_source:
  backend: storage | memory
  present_when: <gate predicate>          # see Presence gate
  # storage backend (Eufy):
  store_key: "robovac_mqtt.{device_id}"
  store_version: 1                         # guard; mismatch -> unavailable
  fields:
    segmentation: map_data.room_pixels
    origin:       map_data.origin_x/origin_y
    resolution:   map_data.resolution
    dims:         map_data.width/height
    room_names:   map_data.room_names
    robot:        robot_trail[-1]
    dock:         dock_pixel
  # memory backend (Roborock):
  hass_data_domain: roborock
  locate: <how to find the coordinator for this vacuum>
  fields:
    rooms:  "MapData.rooms[] -> x0,y0,x1,y1,number,name"
    image:  "..."
    robot/charger: "..."

Presence gate (the unifier)

Each backend is active only when its source artifact is present β€” the same capability-by- presence mechanism the VA already uses (cv_available, supports_map_bounds, …):

  • Eufy: active iff the camera/map artifact exists (camera.<device>_map resolved and .storage map_data present). An older or plain Eufy install (no live-map camera) β†’ inactive β†’ segmentation features hide.
  • Roborock: active iff hass.data["roborock"] coordinator + a parsed MapData exist.
  • Future models: declare their own present_when. Core just asks "is the source present?" β€” degrade to unavailable/hidden when not, never crash.

Reader output (VA-owned, normalized)

Per room: {number, name, bbox (normalized 0–1), polygon (when available), label_pos, width_m, height_m (real-world box size in metres β€” Eufy res-derived, Roborock mm-derived)}; plus current_room (pixel/segment lookup), dock_anchor, robot_anchor (normalized). Published as snapshot fields (sensors optional later).

Wave 3a β€” full overlay extraction (built 2026-06-19)

The reader now extracts the device's other authoritative map layers, all in the SAME normalized space, so the card can OVERLAY them on the device-rendered backdrop (never our own render):

  • Both: per-room area_m2, current_room (Eufy = exact pixel lookup; Roborock = vacuum_room), robot path (decimated), dock_anchor/robot_anchor.
  • Roborock also: robot_heading, no_go/no_mop (4-pt polygons), walls (segments), zones (rects), obstacles ({pos,type,has_photo}) β€” all via the shared _mapdata_projector. Obstacle metadata sits on Obstacle.details in vacuum-map-parser-base (type is an int); _proj_obstacles normalizes the known ints to stable slugs (cable, pet_waste, …, see _OBSTACLE_TYPE_SLUGS) that the card translates via vocab.obstacle_type.*; unknown ints pass through as the number string. (The first cut read a flat o.type/o.photo that the parser never had β€” every marker shipped type=None, fixed 2026-08-06.) Note the local S6 has no obstacle camera, so this layer stays empty on the reference device β€” value set verified against vacuum-map-parser-roborock 0.1.5 (the HA 2026.8.0 pin), not live data.
  • Eufy hazards (forbidden_zones/ban_mop_zones/virtual_walls) are DEFERRED β€” empty on the live device, so their populated coordinate frame is unverified; wired when a live map carries them.

Wave 3b β€” visibility toggles + sensor mirror (built 2026-06-19)

  • Per-layout visibility β€” 11 toggleable layers with defaults (Navigation + room labels/area ON; hazards/activity OFF). OVERLAY_VISIBILITY_DEFAULTS + resolve_overlay_visibility(stored) in map_source.py; only the user's DELTAS are stored (overlay_visibility on the map bucket), merged over defaults at read time so defaults can evolve.
  • Service eufy_vacuum.set_map_overlay_visibility (vacuum_entity_id, map_id, partial visibility map, or reset:true) β€” schema rejects unknown layer keys; returns the resolved map.
  • Snapshot carries map_overlay_visibility (resolved), independent of map_state_source presence.
  • Sensor sensor.<vac>_map_overlays: state = current room name (recorded β†’ room timeline); attributes mirror the overlay layers + visibility for automations/templates. path omitted; the verbose layers are recorder-excluded. Refreshed by a 60 s platform timer + every snapshot fetch.
  • Path hard-cap β€” _decimate_step (ceil stride) truly caps sampled polylines.

Wave 3c β€” card rendering + toggle UI (built 2026-06-19)

The card draws the overlay layers over the live backdrop and a "Map Layers" panel toggles them. Additive only β€” existing room regions / mascots / labels are untouched.

  • Renderer (src/renderers/map.js): _renderDeviceOverlaySvg (current-room rect, no-go/no-mop polygons, zones, walls, path) + _renderDeviceOverlayHtml (robot dot + heading arrow, dock, obstacle markers, mΒ² area chips), inside the rotator so they turn with the map.
  • Letterbox correction (_overlayTransform): overlays are normalized to the IMAGE frame, but the live <img> is object-fit:contain in a SQUARE box β€” so coords are mapped imageβ†’container using image_size (now in map_state_source) and the SAME math as the zone path (state._rectToNormalized, inverted). Without this every layer drifts on a non-square map.
  • Gate: state.isLiveImageDisplayed() β€” overlays render only when the live device image is the displayed backdrop (Roborock always; Eufy when on the live source, not the CV image).
  • Toggle panel (_renderMapLayersPanel, side column) β†’ set_map_overlay_visibility, with an optimistic flip that rolls back on service failure.
  • Theming: --evcc-map-ov-* tokens (no hardcoded colors).

Reviewed via a multi-agent adversarial pass (13 raised β†’ 4 confirmed β†’ fixed: the letterbox mismatch, the optimistic-rollback gap, + image_size plumbing). Deferred (pre-existing, low): live-map rotation is gated on hasLiveImage not isLiveImageDisplayed.

Self-render (client-side canvas) β€” Wave 1 (built 2026-06-19)

The card draws its OWN full-grid map backdrop from the device's room raster β€” no server dependency (no Pillow). The VA owns the frame, so the overlays align perfectly (no live-map-camera crop). Adapter-driven, brand-agnostic core + card.

  • Adapter: a map_render: {format} block declares the decode format (Eufy room_pixels_v1) and reuses the map_state_source store pointer (no duplicate schema). Roborock omits it β†’ supports_va_render: false β†’ the card hides the toggle (its HA-core render is already frame-matched).

Current state (2026-08-06): Roborock has since gained its own map_render block (format: "roborock_raw_map_v1", adapters/roborock/adapter.py β€” re-decodes the raw map blob's segment layer to a per-pixel room-id raster), so supports_va_render is now True for both brands (gated on isinstance(_adapter_cfg.get("map_render"), dict) in core/manager.py::get_dashboard_snapshot). The "Roborock omits it" line above records the Wave-1 (2026-06-19) state. Details: 22 β€” The Adapter Contract / 24 β€” The Roborock Adapter. - Service get_map_render_data β†’ manager.async_get_map_render_data (a thin delegator to MapSourceCoordinator.async_get_map_render_data in mapping/map_source_coordinator.py) dispatches by map_render.format, executor-reads .storage, returns the raster (room_pixels b64) + explicit decode params (dims, ro_dx/dy, flip_y, rid_shift, catch_all_rid, room_names, version) β€” so the card decodes with no brand assumptions. - Card: a <canvas> backdrop (same object-fit:contain as the live <img>, so it letterboxes identically and the overlays align via image_size); a per-vacuum toolbar toggle; fetch-once-cached-by-version; the decode is memoized by version. The overlay + panel gate generalizes to overlaysAligned() = live image or VA render. - Reviewed (19 raised β†’ 4 confirmed, all low β†’ fixed: ImageData memoization, the loading/fallback empty-state, the failed-fetch sentinel). - Wave 1 = rooms only (room_pixels). Walls/floor (raw_pixels β€” different encoding), themed graph-coloring, and the in-memory live-pose re-render (kills the dynamic lag) are later waves.

Waves (each shippable, gated, additive β€” not a rewrite)

  1. Reader + both backends + presence gate. Output: per-room bbox + names. No consumer wiring β€” expose + verify only.
  2. Eufy backend: build now (storage shape proven).
  3. Roborock backend: starts as a defensive runtime introspector (walks hass.data["roborock"], finds the parsed MapData, dumps rooms bbox+name to log/snapshot) β€” because the exact hass.data structure isn't knowable offline. This is the live Roborock test; tune the access path from what it finds.
  4. Exact polygons. Eufy: contour-trace room_pixels (L-exact). Roborock: reconstruct (color-seg render or re-parse segment layer).
  5. Wire consumers. Auto-gen select-regions (replace manual compose) β†’ tap β†’ room number/id β†’ native clean; current-room (replace bbox/sample membership); mascot follow/dock; zone mΒ².

Defensive contract

Adapter-config paths (re-point, don't rewrite, when eufy-clean's schema shifts at #136); Store-version guard; degrade-to-unavailable on any mismatch; brand-agnostic core, coupling only in adapter + reader.

Risks

  • Schema coupling to provider internals (Eufy storage shape β€” esp. the #136 merge; Roborock in-memory attrs). Contained by the adapter pointer + defensive parse + presence gate.
  • Roborock exactness needs a reconstruction step (color-seg / re-parse) β€” dev cost, not user cost.
  • Roborock backend can't be verified offline β€” W1 introspector confirms it at runtime.

Dreame frame alignment β€” map_frame_offset_mm (live-tuned, per-model if it drifts)

The Dreame camera_attrs backend renders from the decoded MapData via a dims-based projection (_dreame_projector). That projection sits ~1 robot-diameter off Dreame's OWN render of the same data β€” the frame the vendor app draws and the robot obeys. The adapter's map_state_source.map_frame_offset_mm = [x_mm, y_mm] (vacuum frame, +x = east, +y = north) is a fixed shift added to every vacuum coord before projection, nudging the WHOLE raster β€” rooms, robot/dock anchors, furniture β€” onto Dreame's frame. Same "tune it on the live device" pattern as the Roborock introspector above and as map_pixel_size. Current value: [30, 290], tuned live on vacuum.robin (dreame.vacuum.r2469a, L10s Ultra Gen 2).

It is a single value in the shared adapter block, verified on ONE model β€” the only Dreame available to tune against. Whether it generalises across models cannot be checked offline (same shape as the Roborock introspector risk).

The stakes are COSMETIC, not functional — this is why a wrong value can't misdirect the robot. The offset is applied in TWO places that carry it identically (replica RN0Y49XS): the render's _n AND dreame_correspondences_from_mapdata (the go-to / zone affine's input pairs). So it cancels in the tap→dispatch round-trip: a tap at the rendered position of a physical point inverts back to that true point regardless of the offset value, because the correspondences shift by exactly what the render shifted. Coordinate dispatch (go-to, zone) is therefore offset-ROBUST; room-clean uses segment ids and never touches it. What the offset actually buys is render fidelity — our map lining up with the Dreame app, and the robot/dock/furniture overlays sitting where the app shows them. A wrong value for some future model shows up as our render sitting shifted from the vendor app, never as a go-to landing in the wrong place.

How to re-tune for a new model (the fix, since we can't pre-verify): overlay the card's render on a Dreame-app screenshot at the same zoom (a docked robot + a distinctive notch is enough); if shifted, adjust [x_mm, y_mm] and redeploy (backend change β†’ full restart), then re-compare β€” exactly how [30, 290] was found. If it turns out to differ per model, promote map_frame_offset_mm from the shared map_state_source block in adapters/dreame/adapter.py to a per-model value in adapters/dreame/model_catalog.py (keyed like has_path_control), and read it in the profile the way the other per-model fields are β€” one offset per model, defaulting to the current shared value.

Provenance: [30, 290] validated live 2026-08-30 β€” a go-to target placed on the card appeared at the same spot in the Dreame app and the robot drove there (a differential proof that our render frame and Dreame's agree).