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.
Roborock specifics (learned at runtime, 2026-06-19)¶
- Parsed geometry is NOT on the coordinator (
runtime_datacarries 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 acloud_api.get_map_v1()re-fetch+parse β not required here). map_data.roomsisdict[int, Room];Room.x0..y1are vacuum coords (~50Γ the pixel grid), so they MUST be projected viaimage.dimensions.to_img(point).rotated(dims)and normalized byimage.data.size(the rendered size =dims.width/height Γ scale). Reader calls that transform (via a lightweight_XYpoint shim) rather than reimplementing it.- The reader targets an object with BOTH
.roomsand.image(aMapData) β never a generic x0/y0/x1/y1 collection, becauseno_go_areas/walls/zones are rectangles too. Room.name/pos_x/pos_yareNonefor Roborock β names fall back toRoom {number}; thenumberis 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>_mapresolved and.storagemap_datapresent). An older or plain Eufy install (no live-map camera) β inactive β segmentation features hide. - Roborock: active iff
hass.data["roborock"]coordinator + a parsedMapDataexist. - Future models: declare their own
present_when. Core just asks "is the source present?" β degrade tounavailable/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), robotpath(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. - 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)inmap_source.py; only the user's DELTAS are stored (overlay_visibilityon 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, partialvisibilitymap, orreset: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.pathomitted; 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>isobject-fit:containin a SQUARE box β so coords are mapped imageβcontainer usingimage_size(now inmap_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 (Eufyeufy_room_pixels_v1) and reuses themap_state_sourcestore pointer (no duplicate schema). Roborock omits it βsupports_va_render: falseβ the card hides the toggle (its HA-core render is already frame-matched). - Service
get_map_render_dataβmanager.async_get_map_render_data(a thin delegator toMapSourceCoordinator.async_get_map_render_datainmapping/map_source_coordinator.py) dispatches bymap_render.format, executor-reads.storage, returns the raster (room_pixelsb64) + 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 (sameobject-fit:containas the live<img>, so it letterboxes identically and the overlays align viaimage_size); a per-vacuum toolbar toggle; fetch-once-cached-by-version; the decode is memoized by version. The overlay + panel gate generalizes tooverlaysAligned()= 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)¶
- Reader + both backends + presence gate. Output: per-room bbox + names. No consumer wiring β expose + verify only.
- Eufy backend: build now (storage shape proven).
- Roborock backend: starts as a defensive runtime introspector (walks
hass.data["roborock"], finds the parsedMapData, dumps rooms bbox+name to log/snapshot) β because the exacthass.datastructure isn't knowable offline. This is the live Roborock test; tune the access path from what it finds. - Exact polygons. Eufy: contour-trace
room_pixels(L-exact). Roborock: reconstruct (color-seg render or re-parse segment layer). - 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.